Containerized GIS Test Runtimes
A containerized GIS test runtime is the versioned image that turns a spatial suite from “reproducible on my machine” into “reproducible everywhere.” It exists because the correctness of a spatial assertion depends on a stack of C libraries — GDAL for I/O, GEOS for geometry, PROJ for transforms — plus PROJ’s separately versioned datum-grid database, and any of those can change a result between builds. This pattern sits beneath CI/CD spatial quality gates and covers how to build an image that fixes every layer of that stack, how to choose between system packages, pinned wheels and conda, and how to stamp the resulting versions into the runtime so a gate verdict is traceable to an exact engine set. Get this right and a topology check produces the same answer on a laptop, a pull-request runner and a nightly job; get it wrong and you ship a gate that flakes on infrastructure, not on data.
The image is the unit of reproducibility, and every layer in that stack has bitten a team somewhere. The two dominant build strategies — pinning system libraries directly and resolving everything through conda-forge — each get a full worked guide: pinning GDAL/PROJ versions in Docker test images and reproducible conda environments for spatial CI.
What Each Layer Pins and Why
| Layer | Pin mechanism | What drifts if unpinned | Symptom in the gate |
|---|---|---|---|
| Base OS | Image digest (@sha256:…) |
System library versions | Rebuild changes GEOS/GDAL |
| GDAL / GEOS / PROJ | Package version or built-from-source tag | Predicate and transform results | Topology/area verdict flips |
| PROJ datum grid | PROJ_DATA version |
Datum-shift accuracy | Round-trip drift moves by metres |
| Python bindings | Pinned wheel (==) with hashes |
ABI mismatch to C libs | Import errors or silent behaviour change |
| Test suite | Git commit | The assertions themselves | Expected |
System Packages vs Wheels vs Conda
Three provisioning strategies dominate, and the right one depends on how much control you need over the exact C-library build. System packages (apt install gdal-bin libgdal-dev) are simple but tie you to the distribution’s release cadence. Pinned wheels install fast and cache well, but the manylinux wheels bundle their own GEOS/PROJ, so you must verify which versions the wheel actually ships. Conda-forge resolves the whole stack — C libraries and bindings together — with a lockfile, which is the most reproducible but the heaviest image.
# Strategy A — system packages pinned to a distribution snapshot
FROM ubuntu:24.04@sha256:... # digest-pinned base
RUN apt-get update && apt-get install -y --no-install-recommends \
gdal-bin=3.8.4+dfsg-1build1 libgdal-dev=3.8.4+dfsg-1build1 \
proj-bin=9.3.1-1 && rm -rf /var/lib/apt/lists/*
# Strategy B — pinned wheels; the wheel bundles GEOS/PROJ, so verify at build time
FROM python:3.12-slim@sha256:...
COPY requirements.lock .
RUN pip install --require-hashes -r requirements.lock
RUN python -c "import shapely; print(shapely.geos_version)" # assert expected GEOS
Pinning the PROJ Datum Grid
The most overlooked layer is the PROJ datum-grid database — the proj-data files that back high-accuracy datum transforms. Two runtimes with identical PROJ library versions but different grid versions can disagree on a NAD83↔WGS84 transform by more than a metre, which reads as a CRS round-trip failure with no code change. Pin the grid explicitly and point PROJ_DATA at it so the transform accuracy is fixed alongside the library.
ENV PROJ_DATA=/opt/proj-data
RUN mkdir -p $PROJ_DATA && \
curl -sL https://download.osgeo.org/proj/proj-data-1.18.tar.gz \
| tar xz -C $PROJ_DATA # fixed grid release, not "latest"
Stamping Versions into the Runtime
A pinned runtime is only useful if a gate can prove which versions it ran against. Emit a version manifest at container build time and read it into every structured log line, so the observability fields the gate architecture records (gdal, geos, proj) come from the runtime itself rather than a guess.
# spatial_runtime.py — one source of truth for engine versions
import shapely, pyproj
from osgeo import gdal
def runtime_manifest() -> dict:
return {
"geos": shapely.geos_version_string,
"proj": pyproj.proj_version_str,
"gdal": gdal.__version__,
"proj_data": pyproj.datadir.get_data_dir(),
}
# conftest.py — fail the suite early if the runtime is not the pinned one
import pytest
from spatial_runtime import runtime_manifest
EXPECTED = {"geos": "3.12.1", "proj": "9.4.1", "gdal": "3.9.2"}
@pytest.fixture(scope="session", autouse=True)
def assert_pinned_runtime():
m = runtime_manifest()
drift = {k: (EXPECTED[k], m[k]) for k in EXPECTED if not m[k].startswith(EXPECTED[k])}
assert not drift, f"runtime drift — expected vs actual: {drift}"
That autouse fixture is a cheap, high-value gate: it fails loudly the moment an image rebuild changes an engine, instead of letting a downstream tolerance check flake mysteriously. It pairs naturally with the spatial tolerance thresholds work, since a drifted engine is the most common cause of a tolerance failure that “makes no sense.”
Common Failure Modes and Gotchas
- Floating base tags.
FROM ubuntu:24.04without a digest lets the base drift; a rebuild months later can carry a new GEOS. Pin@sha256:…. - Trusting the wheel’s version. A pinned
shapely==2.0.4still bundles whatever GEOS that wheel was built against — assertshapely.geos_versionat build time. - Unpinned PROJ grid. Identical PROJ versions with different
proj-datareleases disagree on datum shifts by metres; pin the grid and setPROJ_DATA. - ABI mismatch. Mixing a system GDAL with a wheel-bundled GEOS can produce a runtime that imports but computes subtly differently; keep one provenance per engine.
- No version manifest. Without stamped versions, a regressed nightly gate forces a bisect to answer “did an engine change?” — record the manifest in every log line.
- conda
latestchannels. Resolving conda without a lockfile reintroduces drift; commit an explicit lock — see the conda guide.
Conclusion
A containerized GIS test runtime pins every layer that can change a spatial result — the base digest, the GDAL/GEOS/PROJ versions, the PROJ datum grid, and the Python bindings — and stamps those versions into the runtime so a gate verdict is traceable to an exact engine set. With the image treated as a versioned artifact, spatial gates become deterministic across every machine that runs them, and an engine change becomes a deliberate, reviewable event rather than a mysterious flake. For the gate architecture this runtime underpins, return to CI/CD spatial quality gates.