Pinning GDAL/PROJ Versions in Docker Test Images
A spatial gate is only deterministic if the binary geometry stack behind it is fixed, and a Docker image is the cleanest place to fix it. This guide sits beneath containerized GIS test runtimes and shows how to build an image whose GDAL, GEOS and PROJ versions — plus PROJ’s datum grid — cannot drift between builds, so a topology or CRS verdict is identical on every runner and every laptop that pulls the tag. The specific problem this solves is the “passes locally, fails in CI” report, which almost always traces to two machines running different GEOS or PROJ builds; pinning removes that variable entirely.
Why unpinned images drift
A Dockerfile that starts FROM ubuntu:24.04 and installs GDAL with a bare apt-get install gdal-bin is non-deterministic in two ways. The base tag 24.04 is a moving pointer — rebuilt periodically with newer system libraries — so a rebuild months later can carry a different GEOS. And apt-get install without a version resolves to whatever the distribution currently ships. Either change can shift a make_valid result or a datum transform, flipping a previously green gate with no change to your code. Pinning the base by digest and every spatial package by exact version freezes both.
Where a version number stops meaning anything
The reason pinning is harder than it looks is that a spatial container can contain the same engine more than once, and the copy that answers a question is not always the copy that runs the geometry. Understanding that is what turns pinning from a ritual into an engineering practice.
Pinning-point reference
| Component | Unpinned form | Pinned form |
|---|---|---|
| Base image | FROM ubuntu:24.04 |
FROM ubuntu:24.04@sha256:… |
| GDAL | apt install gdal-bin |
apt install gdal-bin=3.8.4+dfsg-1build1 |
| PROJ library | apt install proj-bin |
apt install proj-bin=9.3.1-1 |
| PROJ datum grid | bundled, unmanaged | fixed proj-data release + PROJ_DATA |
| Python bindings | pip install shapely |
pip install --require-hashes -r lock |
Step-by-step implementation
The Dockerfile below produces an image tagged with the exact engine versions it contains, so the tag itself documents the stack.
Step 1 — Pin the base by digest
# Resolve the digest once: docker pull ubuntu:24.04 && docker inspect ...
FROM ubuntu:24.04@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30
Step 2 — Install GDAL and PROJ at exact versions
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 \
libproj-dev=9.3.1-1 \
&& rm -rf /var/lib/apt/lists/*
Step 3 — Fix the PROJ datum grid
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 # a fixed release, not the newest
Step 4 — Install hash-pinned Python bindings
COPY requirements.lock .
RUN pip install --require-hashes --no-cache-dir -r requirements.lock
Step 5 — Assert the versions at build time
Fail the build if the resolved engine versions are not the ones the tag promises, so a silent apt or wheel change never ships.
RUN python - <<'PY'
import shapely, pyproj
from osgeo import gdal
assert shapely.geos_version == (3, 12, 1), shapely.geos_version
assert pyproj.proj_version_str.startswith("9.3.1"), pyproj.proj_version_str
assert gdal.__version__.startswith("3.8.4"), gdal.__version__
print("engine versions verified")
PY
Deciding how often to move the pin
A pin that never moves becomes a liability of a different kind: an image accumulating unpatched vulnerabilities and drifting away from what anyone runs locally. The question is not whether to upgrade but how to make an upgrade a reviewable event rather than an accident.
The arrangement that works is two tracks running at once. The pinned track is what every gate uses, and it changes only through a pull request that bumps the digest and the versions together. The preview track runs on a schedule against the next candidate versions, on the same suite, and reports separately. When the preview track has been green for a while, promoting it is a small, well-understood change; when it goes red, the failure arrives as scheduled information rather than as a blocked merge on a Tuesday morning.
The cost of the second track is one scheduled job, and it buys the thing that makes pinning sustainable: an upgrade whose consequences were known before it happened. Teams without it face a standing choice between an image that never moves and an image that moves unpredictably, and both are worse.
Verification pattern
After building, confirm the image reports exactly the pinned stack. Any deviation means an upstream package moved and the tag is now lying about its contents.
docker build -t gis-test:gdal3.8.4-proj9.3.1 .
docker run --rm gis-test:gdal3.8.4-proj9.3.1 \
python -c "import shapely,pyproj;print(shapely.geos_version, pyproj.proj_version_str)"
# Expect: (3, 12, 1) 9.3.1
Failure modes and edge cases
- Tag drift without a digest.
FROM ubuntu:24.04is a moving target; a rebuild can silently bump GEOS. Always append@sha256:…. - Wheel-bundled GEOS overrides the system one. Installing a
shapelywheel afterapt-installing GEOS means Shapely uses the wheel’s bundled GEOS, not the system version you pinned — assertshapely.geos_versionto catch the mismatch. - Unpinned PROJ grid. Same PROJ library, different
proj-datarelease, disagrees on datum shifts by metres; pin the grid and setPROJ_DATA. apt-get upgradein the Dockerfile. A stray upgrade step re-floats every pinned package; never upgrade after pinning.- Distribution package retired. A pinned apt version can disappear from the mirror over time; mirror the
.debor build from source for long-lived pins, and revisit the conda approach if system packages prove too volatile.
What to record in the image so a future you can diagnose it
An image that carries its own provenance answers most questions without anyone needing to reconstruct how it was built. Four values, written into the image as labels or as a small file on a known path, cover nearly everything a later investigation asks for.
The base digest identifies the filesystem the build started from, which is the first thing to compare when two images behave differently. The resolved versions of each engine — recorded as the values actually observed at build time rather than the values requested — distinguish “we asked for this” from “we got this”. The lockfile hash ties the image to an exact dependency set without needing the lockfile itself. And the build timestamp with the source revision places the image in history, which is what makes “when did this start” answerable.
None of this changes behaviour, and all of it converts a class of investigation from archaeology into a single command. It is the same principle as recording engine versions in a structured log line, applied one layer down: the cheapest possible insurance against the question you cannot anticipate.
A final point on ordering: put the assertion layer last, after everything it checks. An assertion placed early in the Dockerfile is cached along with the layer it validated, so a later change that invalidates it silently keeps the old, passing result. Placed last, it runs whenever anything beneath it changed, which is exactly when it needs to.
Conclusion
Pinning GDAL and PROJ in a Docker image freezes every layer that can change a spatial result — the base digest, the exact library versions, the datum grid and the hash-pinned bindings — and a build-time assertion proves the tag matches its contents. The image then becomes a portable, deterministic runtime that gives the same gate verdict everywhere. For the broader runtime picture, return to containerized GIS test runtimes.
Tagging the image so the tag is informative
An image tagged latest tells a reader nothing and an image tagged with a build number tells them only when it was made. A tag that encodes the engine versions — the GDAL, GEOS and PROJ versions the image contains — makes the most important property visible wherever the tag appears: in the workflow file, in a job log, in a question about which runtime produced a result.
The cost is that a version bump changes the tag, which is precisely the benefit: the change is visible in the diff of every file that references it. A floating tag hides the same change entirely, which is how a runtime upgrade reaches every pipeline without appearing in any review.
Record the resolved versions in the image itself so a running container can report what it contains without reference to the build that produced it.
The image tag, the assertion layer and the recorded provenance are three expressions of the same idea: make the runtime state a fact the pipeline can check rather than an assumption it inherits.
A short checklist before publishing an image
Five checks catch nearly every pinning defect before a tag reaches a pipeline. Confirm the base is referenced by digest rather than by tag. Confirm every spatial package names an exact version. Confirm the PROJ data package is installed at a stated version and that the environment variable points at it. Confirm the assertion layer is last in the file rather than somewhere convenient in the middle. And confirm the behavioural check — one known coordinate, transformed, compared — is present alongside the version assertions, because it is the only one that catches a correct set of version strings attached to the wrong library.
Running through those takes a minute and removes the class of image that is pinned in four of its five surfaces, which is the class that produces drift nobody can locate.