GitHub Actions Integration Tests with a PostGIS Service Container

A complete GitHub Actions test.yml using a postgis/postgis:16-3.4 service container with a health check, enabling the extension, seeding real geometries, and running async pytest with asyncpg and SQLAlchemy.

← Back to CI/CD Pipelines for Spatial APIs

Stand up a real postgis/postgis:16-3.4 inside a GitHub Actions job, wait for it correctly, enable the extension, seed real geometries, and run async pytest against it — so your spatial queries are tested by the same engine that runs in production.

Context & when to use

A GitHub Actions service container is the lightest way to give a job a real database. You declare an image under services:, the runner starts it before your steps, and your steps reach it over the network. For a FastAPI + PostGIS service this is almost always the right first choice: it is a few lines of YAML, it starts once per job, and it maps cleanly onto the health-check plumbing GitHub already provides. This is the path the CI/CD pipeline overview recommends for the integration stage.

Prefer a service container when every test can share one database version and one schema, and when your steps run directly on the runner (the default). Reach for Testcontainers instead when tests must spin databases up and down in code or need a fresh database per case; reach for docker-compose when you are testing the whole system, not the query. For a single spatial test suite that asserts on ST_Intersects, ST_DWithin, and GiST index selection, the service container wins on simplicity and speed.

The one thing a service container will not do for you is create the PostGIS extension. The image ships the binaries, but CREATE EXTENSION postgis still has to run against your test database before any ST_ function resolves — and that step, plus waiting for the server to actually accept connections, is where most first attempts fail.


Data flow

GitHub Actions job and PostGIS service containerA runner job runs steps in sequence: wait for the pg_isready health check to pass, create the PostGIS extension, seed geometries, then run pytest. All steps connect over localhost port 5432 to a PostGIS service container that the runner started and health-checks.GitHub Actions runner — job "test"1 · wait for health check (pg_isready)2 · CREATE EXTENSION postgis3 · seed geometries (explicit SRID)4 · pytest (asyncpg / SQLAlchemy)steps run on the runner host→ reach the service at localhost:5432PostGIS servicepostgis/postgis:16-3.4container port 5432→ mapped to localhost:5432health-checked by the runnerhealthy?

Runnable implementation

The complete workflow. It declares the service with a health check, waits for readiness, creates the extension, and runs pytest against localhost:5432.

# .github/workflows/test.yml
name: test
on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      postgis:
        # Pin the SAME tag you deploy — CI must not drift from production.
        image: postgis/postgis:16-3.4
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: gis_test
        ports:
          # host:container — reach it at localhost:5432 from runner steps.
          - 5432:5432
        # The runner waits until this command succeeds before starting steps.
        options: >-
          --health-cmd "pg_isready -U postgres -d gis_test"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
          --health-start-period 10s
    env:
      # asyncpg driver URL used by the app + tests. Host is localhost.
      DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/gis_test
      # libpq URL for the psql bootstrap step below.
      PSQL_URL: postgresql://postgres:postgres@localhost:5432/gis_test
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - run: pip install -r requirements-dev.txt

      # PostGIS ships the binaries; the EXTENSION must still be created
      # in the specific database the tests connect to (gis_test).
      - name: Enable PostGIS extension
        run: psql "$PSQL_URL" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS postgis"

      # Apply schema/migrations before seeding.
      - name: Migrate
        run: alembic upgrade head

      - name: Run integration tests
        run: pytest tests/integration -q

The matching pytest fixture creates the engine, guarantees the extension and schema, and seeds a geometry with an explicit SRID — the fixture is the geometry:

# tests/integration/conftest.py
import os
import pytest_asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

DATABASE_URL = os.environ["DATABASE_URL"]  # postgresql+asyncpg://...@localhost:5432/gis_test

@pytest_asyncio.fixture(scope="session")
async def engine():
    eng = create_async_engine(DATABASE_URL)
    async with eng.begin() as conn:
        # Idempotent belt-and-braces in case the workflow step is skipped locally.
        await conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis"))
        await conn.execute(text("""
            CREATE TABLE IF NOT EXISTS zones (
                id   bigserial PRIMARY KEY,
                name text,
                geom geometry(Polygon, 4326)  -- SRID is part of the column type
            )
        """))
        await conn.execute(
            text("CREATE INDEX IF NOT EXISTS idx_zones_geom ON zones USING GIST (geom)")
        )
    yield eng
    await eng.dispose()

@pytest_asyncio.fixture
async def session(engine):
    Session = async_sessionmaker(engine, expire_on_commit=False)
    async with Session() as s:
        # Explicit SRID 4326 — omitting ST_SetSRID/SRID here is the #1 cause of
        # tests that pass locally but return empty sets against real data.
        await s.execute(text("""
            INSERT INTO zones (name, geom) VALUES
            ('unit-square', ST_GeomFromText(
                'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', 4326))
        """))
        await s.commit()
        yield s
        await s.execute(text("TRUNCATE zones RESTART IDENTITY"))
        await s.commit()

A test that only a real PostGIS can satisfy:

# tests/integration/test_zones.py
import pytest
from sqlalchemy import text

@pytest.mark.asyncio
async def test_point_within_zone(session):
    result = await session.execute(text("""
        SELECT name FROM zones
        WHERE ST_Within(ST_SetSRID(ST_MakePoint(0.5, 0.5), 4326), geom)
    """))
    assert result.scalar() == "unit-square"

This same bounding-box-and-predicate shape is the workload described in implementing ST_Within and ST_Intersects in FastAPI — the integration test verifies that endpoint’s query against the real engine.


Key parameters & options

ParameterWherePurpose
image: postgis/postgis:16-3.4services.postgisThe pinned PostGIS build; must equal the deploy tag
POSTGRES_DB: gis_testservice envCreates the database the tests connect to
ports: ["5432:5432"]serviceMaps the container port so runner steps reach localhost:5432
--health-cmd "pg_isready ..."optionsReadiness probe the runner waits on before steps start
--health-interval / --health-retriesoptionsHow often and how many times to probe before failing
--health-start-period 10soptionsGrace window before failing probes count against retries
DATABASE_URL (host localhost)job envasyncpg connection string for app + tests
CREATE EXTENSION IF NOT EXISTS postgisstepRegisters ST_ functions in gis_test
ON_ERROR_STOP=1psql flagFails the step loudly if the extension cannot be created

Gotchas & failure modes

  • function st_within(...) does not exist — the extension was not created, or was created in the wrong database. The image auto-creates it only in the default POSTGRES_DB on some tags and versions; do not rely on that. Run CREATE EXTENSION IF NOT EXISTS postgis explicitly against gis_test, with ON_ERROR_STOP=1 so a failure is not swallowed.

  • could not connect to server: Connection refused — steps started before PostgreSQL accepted connections. The container reports “started” before the server is ready. Never substitute a fixed sleep; use --health-cmd "pg_isready -U postgres -d gis_test" with retries so the runner blocks step execution until the probe passes.

  • getaddrinfo ... Name or service not known for host postgis — you used the service name as the hostname. When your steps run directly on the runner (the default here), the service is reachable at localhost on the mapped port, not by service name. The service name resolves only when the job itself runs inside a container (container: at job level) sharing the service network.

  • Tests pass but production returns nothing — a fixture inserted geometry with SRID=0 (no ST_SetSRID/ST_GeomFromText(..., 4326)). Mixed-SRID comparisons raise Operation on mixed SRID geometries or silently skip the GiST index. Bake the SRID into the column type — geometry(Polygon, 4326) — and into every insert.

  • pg_isready passes but CREATE EXTENSION still races on a slow runnerpg_isready reports the postmaster is accepting connections, which is enough here, but on heavily loaded runners add --health-start-period to avoid early probe failures burning your retry budget before the server is warm.


Verification

Confirm the service, the extension, and the version from inside the job:

# Extension is present in the DB the tests use
psql "$PSQL_URL" -c "SELECT extname, extversion FROM pg_extension WHERE extname='postgis';"

# It is the pinned build, not a surprise upgrade
psql "$PSQL_URL" -c "SELECT PostGIS_Version();"

# The seeded geometry has the SRID you expect
psql "$PSQL_URL" -c "SELECT name, ST_SRID(geom) FROM zones;"
    name     | st_srid
-------------+---------
 unit-square |    4326

An st_srid of 0 means a fixture forgot to set the SRID — fix it before trusting a single spatial assertion.


← Back to CI/CD Pipelines for Spatial APIs