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