Pinning GDAL and PROJ Versions in Spatial Docker Images
Pin the exact GDAL and PROJ versions, build fiona, rasterio, and pyproj against that pinned system GDAL rather than letting wheels vendor their own, and bundle a fixed proj-data grid so every rebuild reprojects to identical coordinates.
Jump to heading Why this matters
Reproducibility is where spatial images quietly break. A Dockerfile that installs gdal-bin and pip install geopandas with no version constraints builds fine today and builds a subtly different stack next month — a newer PROJ, a different transformation grid, a wheel that vendors its own GDAL. The failure mode is the worst kind: not a crash but a wrong answer. The same EPSG:4326 to EPSG:27700 reprojection returns coordinates shifted by centimetres to a metre because PROJ picked a different transformation pipeline, and nothing in the logs says so. When a coordinate feeds a regulated boundary decision or an overlay analysis, that drift is a correctness bug. This task pins every moving part so a rebuild is byte-for-byte predictable. It is the reproducibility half of Docker containerization for spatial workloads, the sibling of setting memory limits for GeoPandas containers.
Jump to heading Prerequisites
- Docker 24+ with a multi-stage build already in place (see Docker containerization for spatial workloads).
- Knowledge of your target GDAL and PROJ versions — for example GDAL 3.6.x and PROJ 9.1.x on Debian Bookworm.
pyproj>=3.6,fiona>=1.9,rasterio>=1.3, and awareness that their PyPI manylinux wheels vendor a private GDAL.
Jump to heading Step-by-step solution
Jump to heading Step 1 — Pin the native GDAL and PROJ versions
Pin exact apt package versions so the system stack is fixed across rebuilds. Query the available version first (apt-cache policy libgdal-dev), then pin it:
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gdal-bin=3.6.2+dfsg-1+b2 \
libgdal-dev=3.6.2+dfsg-1+b2 \
proj-bin=9.1.1-1 \
libproj-dev=9.1.1-1 \
proj-data=9.1.1-1 \
&& rm -rf /var/lib/apt/lists/*
If apt cannot offer the version you need, pin through conda-forge instead, which decouples the geospatial version from the OS release:
FROM mambaorg/micromamba:1.5-bookworm-slim
RUN micromamba install -y -n base -c conda-forge \
"gdal=3.6.2" "proj=9.1.1" "libgdal=3.6.2" \
&& micromamba clean --all --yes
Jump to heading Step 2 — Align the Python wheels to the system GDAL
The manylinux wheels for fiona and rasterio bundle their own GDAL, PROJ, and GEOS. Combined with an apt-installed system GDAL that means two copies in one process. Force the wheels to build against the pinned system GDAL with --no-binary, so exactly one native stack is present:
ENV GDAL_CONFIG=/usr/bin/gdal-config
# Match the Python GDAL binding to the system GDAL major.minor
RUN pip install --prefix=/install \
--no-binary fiona,rasterio,pyproj \
"GDAL==3.6.2" "fiona==1.9.5" "rasterio==1.3.9" "pyproj==3.6.1"
Pinning GDAL==3.6.2 as a Python package to the same version as the system library keeps the osgeo bindings and the C library in lockstep.
Jump to heading Step 3 — Bundle the PROJ data grid
proj.db and the transformation grids must ship inside the image and match the linked PROJ version. Install the matching proj-data (Step 1 already pinned it) and set PROJ_DATA explicitly so PROJ never has to guess:
FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
gdal-bin=3.6.2+dfsg-1+b2 proj-bin=9.1.1-1 proj-data=9.1.1-1 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /install /usr/local
ENV PROJ_DATA=/usr/share/proj \
GDAL_DATA=/usr/share/gdal \
PROJ_NETWORK=OFF
PROJ_NETWORK=OFF is deliberate: it forbids PROJ from fetching transformation grids from a remote CDN at runtime, which would otherwise make results depend on network state.
Jump to heading Step 4 — Verify versions and the proj.db location
Add a build-time check that fails loudly on any mismatch, turning a silent correctness bug into a broken build:
RUN gdalinfo --version && \
projinfo EPSG:27700 >/dev/null && \
python -c "import pyproj; \
assert pyproj.proj_version_str.startswith('9.1'), pyproj.proj_version_str; \
print('PROJ data dir:', pyproj.datadir.get_data_dir()); \
t = pyproj.Transformer.from_crs('EPSG:4326','EPSG:27700',always_xy=True); \
x,y = t.transform(-0.1276, 51.5072); \
assert abs(x-530034) < 5 and abs(y-180381) < 5, (x, y)"
The transform of central London (-0.1276, 51.5072) to British National Grid must land within a few metres of (530034, 180381). If a future rebuild pulls a different PROJ and the pipeline shifts, this assertion fails the build instead of shipping drifted coordinates.
Jump to heading Verification
After the image is built, confirm the runtime stack agrees with the pinned versions:
docker run --rm spatial-image:pinned sh -c '
gdalinfo --version;
proj;
python -c "import pyproj, fiona, rasterio;
print(\"pyproj\", pyproj.__version__, pyproj.proj_version_str);
print(\"datadir\", pyproj.datadir.get_data_dir())"'
# Expect: GDAL 3.6.2, PROJ 9.1.1, datadir /usr/share/proj
Rebuild the image from scratch (docker build --no-cache) and run the same check: the versions and the reprojection result must be identical to the first build. Divergence means something in the chain is still unpinned.
Jump to heading Edge cases and gotchas
- Manylinux wheels double-load GDAL. If you both apt-install GDAL and
pip installthe defaultfiona/rasteriowheels, two GDAL copies load into one process and can disagree on the PROJ data directory, surfacing asCannot find proj.dbor subtly wrong transforms. Choose one source: either rely fully on the vendored wheels and do not apt-install GDAL, or use--no-binaryso the wheels build against the system library. PROJ_LIBversusPROJ_DATA. PROJ 9 readsPROJ_DATA, while older tooling and some libraries still honour the legacyPROJ_LIB. If a component cannot findproj.dbdespitePROJ_DATAbeing set, also exportPROJ_LIBto the same path for backward compatibility.- Network grids change results silently. With
PROJ_NETWORK=ON, PROJ can download a more accurate grid at runtime and quietly change an answer between two identical images. Keep itOFFin production and bundle the exact grid so every replica transforms identically and offline.
Jump to heading FAQ
Why do my reprojection results change after rebuilding the image?
An unpinned build pulled a newer PROJ or a different transformation grid, so the same EPSG:4326 to EPSG:27700 transformation now chooses a different pipeline and returns coordinates that differ by a few centimetres to a metre. Pin the PROJ version, bundle a fixed proj-data package, and set PROJ_DATA explicitly so every rebuild resolves the identical transformation pipeline.
Do the fiona and rasterio wheels bundle their own GDAL?
Yes. The manylinux wheels on PyPI vendor a private copy of GDAL, PROJ, and GEOS inside the wheel. If you also apt-install the system GDAL, two copies load into the same process and can disagree on the PROJ data directory, producing find proj.db errors or wrong results. Either rely entirely on the vendored wheels and do not apt-install GDAL, or install with --no-binary so the wheels build against the pinned system GDAL.
Should I let PROJ download transformation grids over the network?
Not in a production container. PROJ network grids fetch data from a remote CDN on demand, which makes reprojection results depend on network availability and on whichever grid version the CDN serves. For reproducible builds keep PROJ_NETWORK=OFF and bundle the exact proj-data package in the image so every replica transforms identically and offline.
Back to Docker Containerization for Spatial Workloads
Related
- Docker Containerization for Spatial Workloads — the multi-stage image these pinned versions slot into
- Setting Memory Limits for GeoPandas Containers — the runtime memory half of a reproducible spatial image
- Deployment, Scaling & Production Operations — why reproducible reprojection matters across the production stack