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