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.

Two GEOS builds in one container Inside a single container, two independent copies of the GEOS library are present. The first arrives with the system package manager and is installed as a shared library on the system path. The second is bundled inside a Python wheel and lives in the wheel's own directory. A shell command that prints a version resolves to the system copy. The geometry operations performed by the test suite link, through the Python binding, to the bundled copy. A conclusion states that the reported version and the version actually performing the work can therefore differ, and that a version assertion is only meaningful when it queries the library through the same import path the tests use. one container system libgeos from the package manager bundled libgeos inside the wheel shell version query resolves on the system path the test’s geometry calls link through the binding reported ≠ used the number you print and the number doing the work can differ A version assertion is only meaningful when it queries the library through the same import path the tests use. Reading it from a shell command in the Dockerfile checks the copy nothing is running.

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.

A pinned track and a preview track Two horizontal tracks. The pinned track carries the digest and engine versions that every gate uses; it advances only through a reviewed pull request that changes the digest and the versions together, and every merge gate runs against it. The preview track runs the identical suite on a schedule against the next candidate versions, reporting its results separately and blocking nothing. An arrow labelled promote moves the preview versions into the pinned track once the preview has been green for a sustained period. A note contrasts this arrangement with a single-track setup, where an engine upgrade arrives unannounced inside an unrelated change. Pinned track — every gate runs here bump PR bump PR bump PR digest and engine versions change together, in one reviewed change Preview track — scheduled, blocks nothing the same suite, next candidate versions, reported separately promote On a single track, an engine upgrade arrives unannounced inside an unrelated change — and is diagnosed as a data problem.

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.

Assert at build time, not at run time A vertical sequence of Dockerfile layers. First, the base image pinned by digest. Second, system packages installed at exact versions from a pinned distribution snapshot. Third, Python bindings installed from a hash-pinned lockfile. Fourth, the PROJ data package installed at an explicit version. Fifth and last, a run step that imports the geometry library and asserts each version equals its expected value, failing the image build if any does not match. A note records that this places the failure at build time, where one person sees it once, rather than at test time, where it appears to everyone as unexplained geometry differences. FROM base@sha256:… — a digest, not a tag system packages at exact versions, pinned snapshot python bindings from a hash-pinned lockfile PROJ data package at an explicit version RUN python -c "assert every version equals expected" imports the library the tests import — the build fails if anything drifted one person sees a build failure once instead of a team debugging geometry for two days

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

  1. Tag drift without a digest. FROM ubuntu:24.04 is a moving target; a rebuild can silently bump GEOS. Always append @sha256:….
  2. Wheel-bundled GEOS overrides the system one. Installing a shapely wheel after apt-installing GEOS means Shapely uses the wheel’s bundled GEOS, not the system version you pinned — assert shapely.geos_version to catch the mismatch.
  3. Unpinned PROJ grid. Same PROJ library, different proj-data release, disagrees on datum shifts by metres; pin the grid and set PROJ_DATA.
  4. apt-get upgrade in the Dockerfile. A stray upgrade step re-floats every pinned package; never upgrade after pinning.
  5. Distribution package retired. A pinned apt version can disappear from the mirror over time; mirror the .deb or 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.