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.”

The Five Things a “Pinned” Image Usually Still Leaves Loose

Most teams believe their spatial image is pinned, and most are pinning only the top layer. The stack has five independent version surfaces, and pinning four of them produces an image that drifts for reasons nobody can find — because the drift comes from the one that was missed.

The base image tag. A tag is a moving pointer; the same tag today and next month can be two different filesystems. Only a digest is a pin.

The system package set. apt-get install libgdal-dev resolves against whatever the distribution’s index holds at build time. Pinning the package version helps; pinning the distribution snapshot as well is what makes the resolution reproducible.

The Python bindings. A wheel for a spatial binding frequently bundles its own GEOS and PROJ, so the version that matters at runtime is the one inside the wheel, not the one installed by the package manager. Two engines can be present simultaneously, and which answers depends on link order.

The PROJ data package. The EPSG database and the grid shift files version independently of the PROJ library. A correct pipeline with a different grid package produces different coordinates — quietly, and by metres.

The build cache. A layer restored from a cache built three months ago can silently reintroduce an older set of everything above, which is how an image with a correct Dockerfile produces a stale runtime.

Five version surfaces, five different pins Five stacked layers of a container image, each paired with what genuinely pins it and what happens if it is left loose. The base image layer is pinned by digest rather than tag, and left loose the filesystem changes underneath. The system packages layer is pinned by explicit version together with a distribution snapshot, and left loose the resolution differs by build date. The Python bindings layer is pinned by hash in a lockfile, with a note that a wheel may bundle its own GEOS and PROJ so two engines can coexist. The PROJ data layer is pinned by explicit package version because grid files version separately from the library, and left loose coordinates move by metres. The build cache layer is pinned by keying on the lockfile hash, and left loose an old layer silently reintroduces an earlier stack. SURFACE WHAT ACTUALLY PINS IT IF LEFT LOOSE Base image a digest, never a tag the filesystem changes underneath System packages version + distro snapshot resolution differs by build date Python bindings hash-pinned lockfile wheel may bundle its own engine PROJ data package explicit version — it moves alone coordinates shift by metres Build cache key it on the lockfile hash an old layer restores an old stack

The fourth row is the one that produces the most confusing incidents, because it breaks the intuition that a version number describes behaviour. Two containers reporting the same PROJ version can transform the same coordinate to different places if their grid packages differ, and nothing in the usual diagnostic output makes that visible. Recording the data package version alongside the library version — and asserting both at start-up — turns a week-long investigation into a one-line failure.

Verifying the Runtime Instead of Trusting It

A pinned Dockerfile states an intention; only an assertion at start-up establishes a fact. The cheapest possible guard is a session-scoped fixture that reads the actual runtime and fails loudly on any mismatch, before a single test executes.

What to assert, in order of how often it catches something real: the GEOS version as reported by the geometry library actually imported (not by a shell command, which may find a different install), the PROJ library version, the PROJ data package version, and the GDAL version. Then, for anything that matters, a behavioural check — transform one known coordinate and assert the result — because a behavioural assertion catches the case where every version string is right and something else is wrong.

Assert the runtime before the suite runs A start-up guard runs before any test. It performs four version assertions in sequence — the GEOS version as reported by the geometry library that was actually imported, the PROJ library version, the PROJ data package version, and the GDAL version — followed by a behavioural assertion that transforms a single known monument coordinate and compares the output against a recorded expected value. Any mismatch aborts the session immediately with a message naming both the expected and the actual value. A note observes that without this guard the same mismatch surfaces as dozens of unrelated-looking test failures across the suite. session start before any test GEOS, as imported PROJ library PROJ data package GDAL behavioural check transform one known point run the suite abort the session expected vs actual Without the guard, the same mismatch surfaces as dozens of unrelated-looking geometry failures spread across the suite. With it, one message names the cause before anything else has had a chance to be confusing.

The behavioural check earns its place for a specific reason: version strings describe what was installed, not what is being used. A container carrying two GEOS builds — one from the system packages, one bundled in a wheel — reports whichever the query happens to reach, while the geometry operations use whichever the linker resolved. Transforming a known coordinate and comparing against a recorded value bypasses the whole question and tests the thing that actually matters.

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.

Frequently Asked Questions

How large should a spatial test image be?

Smaller than the instinct to include everything suggests, but not at the cost of determinism. A multi-stage build that compiles or installs in one stage and copies only the runtime artefacts into a slim final stage typically lands in the hundreds of megabytes rather than gigabytes, and the difference is real time on every cold runner. What must not be trimmed is the PROJ data package — dropping the grid files to save space is the most expensive optimisation available, because it changes results rather than only size.

Should the database run in the same image as the tests?

No. Run it as a separate service container so its lifecycle, its version and its resource limits are independent, and so a database that fails to start reports as an infrastructure fault rather than as a broken test image. Bundling them also defeats layer caching, since a change to either forces a rebuild of both.

Is a container needed at all if the suite is pure Python?

Yes, for anything spatial. “Pure Python” is not pure: the geometry, projection and driver libraries are compiled dependencies whose behaviour varies by build, and those are exactly the components whose differences produce the confusing failures. A container is the cheapest way to make the compiled layer identical everywhere, which is the property the whole discipline rests on.

How do we keep local development matching CI?

By making the same image the local one. A short script that runs the suite inside the CI image, mounting the working tree, removes the entire category of “works on my machine” for spatial code — and it costs a few seconds of container start. Teams that maintain a separate, convenient local environment eventually spend more time reconciling the two than the convenience ever saved.

What belongs in the image versus in a cached layer?

Anything version-pinned belongs in the image. Anything derived and reproducible — a wheel cache, a fixture built from a seeded generator — belongs in a cache keyed on a hash of its inputs. The distinction matters because an image is rebuilt on a schedule while a cache is restored on every run, and putting a version-pinned dependency in a cache means the pin is only as good as the cache key.

How often should the image be rebuilt if nothing changed?

Weekly is a reasonable default, on a schedule, with the result compared against the current pin. A rebuild that produces a different runtime from identical inputs is itself a finding — usually a tag that moved or a package index that resolved differently — and catching it on a quiet schedule is much better than discovering it during an unrelated change.

Multi-stage builds and what to copy forward

A spatial image built in one stage and shipped from another is smaller and, more importantly, more predictable — because the final stage contains only what was explicitly copied into it rather than everything a build needed. Compilers, headers, package caches and intermediate archives all stay behind.

Two things must be copied deliberately rather than assumed. The shared libraries the bindings link against: copying a Python site-packages directory without the C libraries beneath it produces an image that imports and then fails at the first geometry call, with a message about a missing object rather than about a missing package. And the PROJ data directory, together with the environment variable pointing at it, because a runtime that cannot find its grid files silently falls back to lower-accuracy transformations rather than failing.

The verification for both is the same behavioural check the runtime guard already performs: import the geometry library, transform a known coordinate, compare against the recorded value. If the copy was incomplete, that assertion fails at build time in the final stage, which is exactly where you want to learn it.

When the image is not enough

A container fixes the compiled stack and nothing else. Three things remain outside it and account for most of the residual non-determinism a well-pinned image still shows.

The locale and encoding the process runs under affects how a driver parses attribute text, and a runner defaulting to a different locale than a developer’s machine produces mojibake in exactly one of the two. Set them explicitly in the image. The timezone affects any datetime the pipeline stamps, which matters whenever a fixture hash includes one. And thread-count environment variables for the numerical libraries change scheduling and, occasionally, floating-point reduction order — which is enough to move a computed area in the last digits.

None of these is exotic, and all three are one line each in the image definition. They are worth setting the day the image is created, because each produces a failure that looks like a data problem and is not.

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.

The overall test is simple: can an engineer reproduce a CI result locally by pulling one tag and running one command? If yes, the runtime is doing its job. If reproducing requires installing anything, or matching a version by hand, the image is documentation rather than a guarantee — and the confusing failures it was meant to prevent are still ahead.

Who owns the image

An image with no owner ages badly in a specific way: it stays working, so nobody touches it, and eventually nobody remembers what is in it or why. Naming an owner — a team, recorded in the repository that builds it — is what makes the weekly rebuild and the quarterly upgrade someone’s job rather than everyone’s assumption.

The owner’s responsibilities are small and worth stating: keep the pin current within an agreed window, review what the preview track reports, and answer the question “which image should we use” with one tag rather than a discussion. Where several teams share an image, the owner is also the person who decides that a request for an extra dependency belongs in the shared image rather than in a caller’s own layer — a decision that keeps the base from accumulating everything anyone ever needed.