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.

Two native stacks in one process, and the one that winsOn the left, a single Python process is drawn holding two complete native stacks. The apt-installed GDAL 3.6.2, PROJ 9.1.1 and their data directory sit alongside a second GDAL and PROJ bundled inside the fiona and rasterio manylinux wheels, at whatever versions those wheels were built against. Both are loaded; which one answers a given call depends on link order and on which library got there first, and the two may resolve different proj.db files, so the same reprojection can give different results depending on which module made it. On the right, installing with the no-binary flag against the pinned system GDAL leaves exactly one stack: every module links the same libgdal, the same libproj and the same data directory, and the result of a transform stops depending on import order.manylinux WHEELS + apt GDAL--no-binary AGAINST THE PINNED SYSTEM GDALone Python processapt stackGDAL 3.6.2PROJ 9.1.1/usr/share/projwheel stackGDAL bundled in fionaPROJ bundled in rasterioits own proj.dbWhich one answers depends on link orderand on which module imported first.The same transform can return different metresfrom geopandas than from rasterio.one Python processone pinned stackGDAL 3.6.2 · PROJ 9.1.1PROJ_DATA=/usr/share/projPROJ_NETWORK=OFFfiona, rasterio and pyproj all link this one.A transform's result stops depending onimport order, which is what makes thebuild-time assertion meaningful.Building from source costs minutes on the first build and nothing afterwards, because the layer is cached. Two stacks costan afternoon, once, on the day the numbers disagree.

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.

Three ways the same reprojection can answerOne transform request, from WGS 84 to British National Grid, resolved three ways. With the proj-data package bundled in the image and PROJ_NETWORK off, PROJ finds the OSTN15 grid locally and applies the full shift, landing within about a metre of the surveyed value — deterministic on every rebuild and on every machine. With PROJ_NETWORK on and no bundled grid, PROJ tries to fetch the grid from a remote CDN: it succeeds when the network allows and gives the same accurate answer, and falls back when it does not, so the same container gives different coordinates in different environments. With neither the grid nor the network, PROJ quietly uses a Helmert approximation and returns a point a couple of metres away with no warning, no exception and no log line — the answer looks entirely plausible, which is what makes it dangerous in a dataset that gets joined to survey data.TRANSFORM (−0.1276, 51.5072) FROM EPSG:4326 TO EPSG:27700grid bundled · PROJ_NETWORK=OFFproj-data pinned in the imageOSTN15 applied — (530034, 180381), within a metreidentical on every rebuild, every machine, every networkno grid · PROJ_NETWORK=ONfetch from a CDN at runtimeaccurate when the fetch succeeds, approximate when it does notthe same image now answers differently in staging and productionno grid · no networkthe default of a slim imageHelmert fallback — about 2 m out, silentlyno exception, no warning, no log line — the number simply looks fineTwo metres is invisible on a zoomed-out map and fatal in a join against survey data. That is precisely why the build-timeassertion checks a known point rather than checking that the library merely imports.

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