Releasing a Spatial Dashboard the Same Way Twice
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.
Jump to heading Prerequisites
- A multi-stage
Dockerfileas 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
/readyzendpoint that checks the real dependencies, per adding health-check endpoints. The smoke stage is only as good as what that endpoint actually verifies. pytest>=8and 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.
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
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.
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.
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
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.
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
stackjob must fail on the version assertion, and it must fail before anything is deployed. - Set
PROJ_NETWORK=ONand remove theproj-datapackage. 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_URLat a port with nothing behind it. Thesmokejob 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.