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.

Version alignment for a reproducible spatial imageTop row, aligned: pinned system GDAL and PROJ feed the Python wheels fiona, rasterio, and pyproj, which resolve to a single bundled proj.db data grid, producing identical reprojection. Bottom row, conflict: manylinux wheels vendor their own GDAL and PROJ alongside the system copy, so two GDAL copies load and disagree on the proj.db location.ALIGNED · reproduciblepinned systemGDAL 3.6 · PROJ 9.1wheels built against itfiona · rasterio · pyprojbundled gridone proj.dbCONFLICT · double-loadsystem GDALapt proj.db (A)wheel-vendored GDALbundled proj.db (B)A ≠ Bfind proj.db error

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:

dockerfile
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:

dockerfile
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:

dockerfile
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:

dockerfile
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:

dockerfile
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:

bash
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 install the default fiona/rasterio wheels, two GDAL copies load into one process and can disagree on the PROJ data directory, surfacing as Cannot find proj.db or subtly wrong transforms. Choose one source: either rely fully on the vendored wheels and do not apt-install GDAL, or use --no-binary so the wheels build against the system library.
  • PROJ_LIB versus PROJ_DATA. PROJ 9 reads PROJ_DATA, while older tooling and some libraries still honour the legacy PROJ_LIB. If a component cannot find proj.db despite PROJ_DATA being set, also export PROJ_LIB to 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 it OFF in 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