A spatial dashboard has an unusual release problem. Most Python web applications can be verified by running their tests, and if the tests pass the artefact is good. Here the artefact carries a compiled geospatial stack whose version determines the numbers the application produces — a different PROJ, a different transformation grid, and the same reprojection lands two metres away. A green test suite says nothing about that, because the tests ran in whatever environment the runner happened to assemble.

So a pipeline for this kind of application has two jobs rather than one. The familiar job is the usual: lint, test, build, deploy. The unfamiliar one is proving that the image being deployed computes the same coordinates as the image it replaces, and failing the build loudly when it does not. This page builds a pipeline that does both, in a way that runs in a few minutes rather than twenty, because a pipeline nobody waits for is a pipeline that gets bypassed.

Jump to heading The problem statement

Three things go wrong between a working laptop and a running replica, and they fail at different times in ways that are easy to confuse.

The environment drifts. A base image tag resolves to a new build, an unpinned apt package moves a minor version, a wheel is published that bundles a different GDAL. Nothing in the application changed, and the output did.

The build succeeds while the image is broken. Python wheels installed without their native runtime libraries produce an image that imports cleanly in the builder stage and fails at import fiona in the runtime stage — or, worse, imports fine and cannot find proj.db, which is not an error at all but a silent fallback to an approximate transform.

The deploy succeeds while the application is unhealthy. The container starts, binds its port, passes a liveness probe that only checks that the process is alive, and cannot reach PostGIS. Traffic is routed to it and every map is empty.

A pipeline that only runs unit tests catches none of the three. The additions that catch them are small: pin the environment, assert the geospatial stack inside the built image, and gate the deploy on a readiness check that exercises the real dependencies.

Five stages, five failures each one exists to catchThe pipeline runs left to right. Lint and unit tests catch logic errors and take under a minute, running against the repository rather than against an image. The build produces the image and catches nothing on its own — a successful build is not evidence of anything. The stack assertion runs inside the built image and is where environment drift is caught: it checks the GDAL and PROJ versions, that proj.db is found where PROJ_DATA says it is, and that a known control point reprojects to its surveyed coordinates within a metre. The smoke stage starts the container and exercises the readiness endpoint, which catches an image that imports cleanly but cannot reach its database. Only then does the deploy stage run, and it is gated on every stage before it. Underneath, the stage each of the three common failures is caught by: environment drift at the assertion, a broken image at the smoke test, and an unhealthy deploy at the readiness gate — none of them at the unit tests, which is why a green test suite is not a release criterion here.WHAT EACH STAGE IS ACTUALLY FORlint + unit< 60 sbuild imagelayer cacheassert the stackinside the imagesmokestart it, hit /readyzdeployThe three failures, and where each is caughtenvironment drift — a moved base tag or a rebuilt wheel changes the coordinates the app producescaught by the stack assertion, which checks versions, the proj.db path, and a known control pointa build that succeeds and an image that is broken — wheels present, native libraries missingcaught by the smoke stage, because the runtime stage is the first place the import actually runsa deploy that succeeds and an application that cannot serve — the port is bound, PostGIS is not reachablecaught by gating on readiness rather than on livenessNone of the three is caught by the unit tests, which is why a green suite is not a release criterion for this kind of application.

Jump to heading Prerequisites

  • A multi-stage Dockerfile as described in Docker containerization for spatial workloads, with the base image and the GDAL and PROJ packages pinned.
  • A container registry the pipeline can push to, and credentials held as repository secrets rather than in the workflow file.
  • A /readyz endpoint that checks the real dependencies, per adding health-check endpoints. The smoke stage is only as good as what that endpoint actually verifies.
  • pytest>=8 and a small fixture dataset committed to the repository — a few hundred features is plenty, and it must be committed rather than downloaded, so the tests do not depend on a network the runner may not have.

Jump to heading Core implementation workflow

Jump to heading Step 1 — Split the workflow so the fast stage fails first

Run the cheap checks against the repository, before any image is built. A syntax error should be reported in forty seconds, not after a four-minute image build.

yaml
name: release
on:
  push: { branches: [main] }
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12", cache: pip }
      - run: pip install -r requirements-dev.txt
      - run: ruff check .
      - run: pytest tests/unit -q          # no geo stack, no network, seconds

Keep tests/unit genuinely unit: no GDAL, no database, no fixtures larger than a few hundred rows. The tests that need the geospatial stack belong in the image, where the stack is the one that will actually ship.

Jump to heading Step 2 — Build once, with a cache, and pass the digest forward

yaml
  build:
    needs: check
    runs-on: ubuntu-latest
    permissions: { contents: read, packages: write }
    outputs:
      digest: $
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: $
          password: $
      - id: push
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/$:$
          cache-from: type=gha
          cache-to: type=gha,mode=max

Two details matter more than they look. Tagging by commit SHA rather than by latest means every later stage refers to one specific image, so the thing that was tested is provably the thing that is deployed. And the layer cache is what makes this bearable: as the layer-ordering section explains, a Dockerfile that copies requirements before source will reuse the geo-wheel layer on every build where dependencies did not change, which is the difference between a four-minute pipeline and a forty-second one.

Jump to heading Step 3 — Assert the geospatial stack inside the image

This is the stage that distinguishes a spatial pipeline from a generic one. Run it in the built image, not on the runner, because the runner’s environment is irrelevant.

yaml
  stack:
    needs: build
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/$:$
      credentials:
        username: $
        password: $
    steps:
      - run: |
          python - <<'PY'
          import pyproj, geopandas, rasterio, fiona
          print("PROJ", pyproj.proj_version_str, "| data:", pyproj.datadir.get_data_dir())
          assert pyproj.proj_version_str.startswith("9.1"), pyproj.proj_version_str
          assert geopandas.__version__.startswith("1."), geopandas.__version__

          # A known control point must land where it landed last release.
          t = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True)
          x, y = t.transform(-0.1276, 51.5072)
          assert abs(x - 530034) < 1 and abs(y - 180381) < 1, (x, y)
          print("control point OK:", round(x), round(y))
          PY

The control-point assertion is the load-bearing line. Version checks catch a deliberate upgrade; the control point catches everything else — a missing transformation grid, a PROJ built without the datum shift, an image where PROJ_NETWORK is on and the grid was fetched from a CDN that happened to be reachable during the build and will not be at runtime. A tolerance of one metre is tight enough to catch a fallback to an approximate transform and loose enough not to fail on floating-point noise.

Pick control points that exercise the transforms the application actually performs. One per datum shift the dashboard relies on is the right granularity, and adding a new one is the correct response to any incident where coordinates were wrong.

Jump to heading Step 4 — Smoke-test the started container

An image that imports cleanly can still fail to serve. Start it, wait for readiness, and fail if it never arrives.

yaml
  smoke:
    needs: [build, stack]
    runs-on: ubuntu-latest
    services:
      postgis:
        image: postgis/postgis:16-3.4
        env: { POSTGRES_PASSWORD: test, POSTGRES_DB: spatial }
        options: >-
          --health-cmd "pg_isready -U postgres" --health-interval 5s
          --health-timeout 5s --health-retries 10
        ports: ["5432:5432"]
    steps:
      - uses: actions/checkout@v4
      - run: psql "$DSN" -f tests/fixtures/schema.sql
        env: { DSN: "postgresql://postgres:test@localhost:5432/spatial" }
      - run: |
          docker run -d --name app --network host \
            -e DATABASE_URL="postgresql://postgres:test@localhost:5432/spatial" \
            ghcr.io/$:$
          for i in $(seq 1 60); do
            code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8081/readyz || true)
            [ "$code" = "200" ] && echo "ready after ${i}s" && exit 0
            sleep 1
          done
          echo "never became ready"; docker logs app; exit 1

Sixty seconds is deliberate: the geospatial import alone can take twenty on a cold container, and a smoke test with a ten-second timeout fails on a perfectly healthy image, which teaches everyone to ignore it. Dumping the container logs on failure is what makes the stage debuggable — without it, a red build says “never became ready” and nothing else.

Jump to heading Step 5 — Deploy the digest, not the tag

yaml
  deploy:
    needs: smoke
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: |
          gcloud run deploy spatial-dashboard \
            --image ghcr.io/$@$ \
            --region europe-west1 --no-cpu-throttling --concurrency 8

Deploying by digest closes the last gap. A tag can be overwritten between the smoke test and the deploy — by a concurrent pipeline, by a retry, by somebody pushing manually — and the digest cannot. It is the difference between “we deployed the image we tested” being a convention and being a fact.

Where the pipeline minutes goTwo runs of the same pipeline are traced. On a cold layer cache, lint and unit tests take about forty seconds, the image build takes three and a half minutes — almost all of it installing and compiling the geospatial wheels — the in-image stack assertion takes fifteen seconds, and the smoke test takes about fifty, most of which is the container's own import phase. Total: a little over five minutes. On a warm cache, where only application source changed, the wheel layer is reused and the build drops to about twenty-five seconds; every other stage is unchanged, and the total is under two and a half minutes. The practical consequence is that the ordering of the Dockerfile is a pipeline-latency decision as much as an image-size one, and that the stages worth optimising are not the ones that look slow but the ones that are paid on every commit.ONE PIPELINE RUN, COLD CACHE AND WARMcold layer cache — 5 min 15 slint 40 sbuild 3 m 30 s — almost all of it the geo wheelsstack 15 ssmoke 50 swarm layer cache, source-only change — 2 min 10 slint 40 sbuild 25 sstack + smokeThe Dockerfile's layer order is a pipeline-latency decision as much as an image-size one — and thestages worth optimising are the ones paid on every commit. What to assert in the image, beyond the version numbersA version assertion catches a deliberate upgrade and little else, so the in-image stage is worth more when it checks behaviour. The PROJ data directory should resolve to the path the image set, because a wheel bundling its own grids will silently shadow it. Network access for grid fetching should be off, because a build that succeeded with it on will produce different coordinates on a runtime host that cannot reach the CDN. A control point should reproject to within a metre of a surveyed value, which is the check that actually catches a missing datum shift. And the format drivers the application relies on should be present, because a trimmed GDAL build fails on the first file of a format nobody tested with.FOUR IN-IMAGE ASSERTIONS WORTH MORE THAN A VERSION CHECKproj.db resolves to the expected patha wheel bundling its own grids shadows the system one without any warningPROJ_NETWORK is offotherwise the build's coordinates depend on whether a CDN was reachable that afternoona control point lands within a metrethe only check that catches a missing datum shift, which raises nothing on its ownthe format drivers you rely on exista trimmed GDAL fails on the first file of a format nobody thought to testAdd a new control point every time an incident turns out to have been a coordinate problem — that is the checkthat would have caught it.

Jump to heading Advanced patterns

Multi-architecture images. Teams on Apple silicon and clusters on x86 make a single-architecture image a recurring annoyance, and docker/build-push-action will build both with a platforms list. The cost is real: an emulated linux/arm64 build of the geospatial stack is several times slower than a native one, so use native runners for each architecture and join them into a manifest rather than emulating.

Nightly drift detection. The pipeline above pins everything, which means it will not notice that a pinned version has been superseded. A scheduled job that rebuilds with the pins removed, runs the stack assertion, and opens an issue when the control point moves gives you the upgrade signal without letting the drift into a release. It is the only place latest belongs.

Preview environments per pull request. Deploying every branch to its own revision is straightforward once the image is tagged by SHA, and for a spatial dashboard it is unusually valuable, because most of what reviewers need to check — does the map render, is the projection right, is the legend readable — cannot be seen in a diff. Tear them down on merge, and give them a smaller instance so the cost stays proportionate.

Jump to heading Verification and testing

The pipeline itself needs verifying, and the way to do that is to break things deliberately and confirm the right stage goes red.

  • Change the pinned PROJ version by one minor release and push. The stack job must fail on the version assertion, and it must fail before anything is deployed.
  • Set PROJ_NETWORK=ON and remove the proj-data package. The control point must move past the one-metre tolerance and fail the build — this is the check that catches the silent approximate transform described in pinning GDAL and PROJ versions.
  • Point DATABASE_URL at a port with nothing behind it. The smoke job must time out and print the container logs, and the deploy must not run.
  • Push twice in quick succession. Both runs must deploy their own digest, and the second must not overwrite the first’s image under it.

A pipeline whose failure modes have never been observed is a pipeline nobody trusts, and an untrusted pipeline gets bypassed with a manual deploy on the first urgent afternoon.

Jump to heading Troubleshooting

The build is slow on every run despite the cache. Something above the dependency install changes every time — a COPY . . early in the file, or a build argument that embeds the commit SHA into an early layer. Move both below the install.

ImportError: libgdal.so.32: cannot open shared object file in the smoke stage. The runtime stage copied the Python wheels but not the native runtime packages. This is exactly the failure the smoke stage exists to catch, and the fix belongs in the Dockerfile rather than in the pipeline.

The stack assertion passes locally and fails in CI. The local run is using a cached image built before the pin changed. Build with --no-cache once to confirm, then check that the pin in the Dockerfile is the pin you think it is.

The smoke test passes but production is unhealthy. The /readyz endpoint is not checking what production depends on. If it returns 200 without touching PostGIS, it is testing that the process is alive, which liveness already covered.

Deploys are racing. Two merges to main run concurrently and the second finishes first. Add a concurrency group keyed on the branch with cancel-in-progress: false, so deploys serialise in merge order.

Jump to heading Performance considerations

The number to watch is time-to-red, not total pipeline duration. A pipeline that reports a syntax error in forty seconds and a broken projection in five minutes is more useful than one that reports both in three, because the first failure is the one that happens most often and the developer is still holding the context.

Keep the image build off the critical path where possible. Unit tests, linting and type checks need no image; running them in a parallel job that starts at the same time as the build means a failure in either is reported as soon as it is known rather than after the slower one finishes.

Finally, treat cache hit rate as a metric rather than a hope. A pipeline whose layer cache misses on every run is spending several minutes per commit reinstalling a geospatial stack that has not changed in months, and the cause is almost always a single misplaced COPY. It is worth checking once and cheap to keep right afterwards.

Back to Deployment, Scaling & Production Operations.