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 layered spatial runtime stack, every layer pinned A vertical stack of five layers, each pinned. Base OS at a fixed digest at the bottom, then GDAL, GEOS and PROJ C libraries at exact versions, then the PROJ datum-grid database at a fixed version, then the pinned Python bindings fiona, Shapely and pyproj, then the test suite on top. A manifest on the side reads every layer's version. Base OS image · fixed digest GDAL · GEOS · PROJ · exact versions PROJ datum-grid database · fixed fiona · Shapely · pyproj · pinned wheels Spatial test suite Version manifest gdal 3.9.2 geos 3.12.1 proj 9.4.1 grid 1.18

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

  1. Floating base tags. FROM ubuntu:24.04 without a digest lets the base drift; a rebuild months later can carry a new GEOS. Pin @sha256:….
  2. Trusting the wheel’s version. A pinned shapely==2.0.4 still bundles whatever GEOS that wheel was built against — assert shapely.geos_version at build time.
  3. Unpinned PROJ grid. Identical PROJ versions with different proj-data releases disagree on datum shifts by metres; pin the grid and set PROJ_DATA.
  4. 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.
  5. 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.
  6. conda latest channels. 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.