Pinning GDAL & PROJ Versions in Docker Images
A marine pipeline that computes a datum shift correctly on a developer laptop and then returns a subtly different depth or position inside a container is almost never a code bug — it is a version-drift bug, and it belongs to the containerization concerns covered in Containerizing Marine Geospatial Pipelines with Docker. The geospatial stack is unusually fragile here because a single logical operation — reproject these soundings from NAD83 to a tidal working frame — is resolved by a chain of independently versioned components: pyproj ships its own bundled PROJ, system GDAL may link a different PROJ, and the actual numbers depend on which PROJ transformation grids happen to be present on disk. When any link in that chain differs between build and run, you get either a hard PROJ: proj_create: no database context import error or, far worse, a silent sub-metre shift that corrupts every downstream product. This guide pins the whole stack deterministically, bakes the correct grids into the image, and fails the build loudly when anything drifts.
Root Cause: One Transform, Four Version-Sensitive Components
The reason a “reprojection” is nondeterministic across images is that it is not a single library call. Four separately packaged pieces cooperate, and each has its own version:
import pyproj
import rasterio
from osgeo import gdal
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger("crs-diag")
# pyproj carries its OWN bundled PROJ, independent of system GDAL's PROJ
logger.info("pyproj: %s (bundled PROJ %s)", pyproj.__version__, pyproj.proj_version_str)
logger.info("rasterio:%s (linked GDAL %s)", rasterio.__version__, rasterio.__gdal_version__)
logger.info("GDAL: %s", gdal.__version__)
logger.info("PROJ_DATA: %s", pyproj.datadir.get_data_dir())
On one image this prints PROJ 9.3 with a full grid set; on another it prints PROJ 6.3 with only the fallback null grids. Between PROJ 6 and PROJ 9 the entire transformation engine changed: PROJ 6 introduced the PROJ.db SQLite database and pipeline-based transforms, deprecated the old +datum shorthands, and moved grid distribution from bundled .gtx/.gsb files to on-demand projsync/CDN downloads. So the same EPSG pair can resolve to a different transformation pipeline — one using a high-fidelity NADCON/VDatum-style grid, the other falling back to a coarse 3-parameter Helmert shift.
Reproducing the failure
The cheapest reproduction is to run one known transform and diff the result. Pick a coordinate near your survey area and a datum pair that genuinely needs a grid:
import logging
from pyproj import Transformer
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("crs-repro")
# NAD83(2011) geographic -> NAD83(2011) / UTM 18N, ellipsoidal to a grid-dependent frame
transformer = Transformer.from_crs("EPSG:6318", "EPSG:6347", always_xy=True)
x, y = transformer.transform(-75.0000, 38.5000)
logger.info("Result: %.6f, %.6f", x, y)
# Image A (PROJ 9.x, grids present): 456834.267100, 4262124.518900
# Image B (PROJ 6.x, no grids): 456834.221900, 4262124.401300 <-- ~0.12 m drift
A ~0.1 m horizontal or vertical discrepancy is invisible to a smoke test but is fatal for hydrographic work bound by IHO S-44 uncertainty limits. The related pitfalls of getting these pairs structurally wrong are covered in Debugging Axis-Order Inversion in pyproj and the broader Geodetic Datums & EPSG Management workflow; here the concern is strictly that the versions and grids are identical everywhere the image runs.
Step 1 — Pin the Full Stack With a Compatibility Table
Pinning pyproj alone is not enough, because rasterio and fiona link GDAL, GDAL links PROJ, and pyproj bundles yet another PROJ. Pin all four to a row that is known to interoperate. The wheels on PyPI bundle their own GDAL/PROJ, so mixing a wheel rasterio with a system libgdal is the single most common source of drift — pick one provenance (all wheels, or all conda-forge) and stay on it.
| GDAL | PROJ | pyproj | rasterio | Notes |
|---|---|---|---|---|
| 3.6.x | 9.1 | 3.4.x | 1.3.x | First stable PROJ 9 line; safe baseline |
| 3.7.x | 9.2 | 3.5.x | 1.3.x | Adds projsync --list-files; good grid tooling |
| 3.8.x | 9.3 | 3.6.x | 1.3.9 | Recommended pin for new marine images |
| 3.9.x | 9.4 | 3.6.1 | 1.4.x | Verify fiona and pyogrio pins move together |
Encode the row in a fully constrained requirements.txt — exact == pins, never >=:
# requirements-geo.txt — one interoperable GDAL/PROJ/pyproj/rasterio row
pyproj==3.6.1
rasterio==1.3.9
numpy==1.26.4
In the Dockerfile, pin the base image by immutable digest and install the OS-level PROJ/GDAL with exact versions too, so an upstream repository refresh cannot bump them under you:
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim-bookworm@sha256:8f3aba466a471c0d2b6c1c4b0d6e6f4c3a1d2e5b6c7d8e9f0a1b2c3d4e5f6a7b
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
libproj25=9.3.1-1 \
proj-data \
proj-bin \
&& rm -rf /var/lib/apt/lists/*
COPY requirements-geo.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/requirements-geo.txt
Step 2 — Bake the PROJ Transformation Grids Into the Image
Pinned versions still produce wrong numbers if the grids are missing, because PROJ silently falls back to a lower-fidelity pipeline. Bake the grids at build time and turn network fetching off so runtime never depends on the CDN. Use projsync to pull only what your survey footprint needs, or copy a vetted grid directory:
# Bake grids for the working region only; keeps the layer small and deterministic
ENV PROJ_DATA=/opt/proj
RUN mkdir -p /opt/proj && \
projsync --target-dir /opt/proj --bbox -80,36,-72,42 --quiet && \
projsync --target-dir /opt/proj --file us_noaa_g2018u0.tif --quiet
Then make the location and the network policy explicit as environment variables so both PROJ (via GDAL) and pyproj resolve to the same baked directory:
# PROJ_DATA is read by both libproj and pyproj; PROJ_NETWORK must be pinned OFF
ENV PROJ_DATA=/opt/proj \
PROJ_NETWORK=OFF
Pinning PROJ_NETWORK=OFF matters for reproducibility: with it ON, PROJ may reach out to cdn.proj.org at runtime and use a grid that was not present at build, quietly reintroducing the drift you just eliminated. If your grids exceed what you want to bake, that is a size trade-off addressed in Reducing Geospatial Docker Image Size — but note that pruning grids too aggressively is exactly what causes this failure, so prune by region, never blindly.
The diagram below shows why baked versions plus baked grids yield one deterministic pipeline, while an unpinned image resolves a different pipeline at runtime:
Step 3 — Assert Versions at Build Time So the Build Fails on Drift
Baking is worthless without an assertion, because a transitive dependency can still slip a different PROJ into the final layer. Add a check script that compares the resolved versions against an expected manifest and raises a non-zero exit, so docker build aborts rather than shipping a bad image:
#!/usr/bin/env python3
"""Fail the Docker build if the resolved geospatial stack drifts from the pin manifest."""
import logging
import sys
from typing import Dict
import pyproj
import rasterio
from osgeo import gdal
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger("version-gate")
EXPECTED: Dict[str, str] = {
"pyproj": "3.6.1",
"proj": "9.3.1",
"rasterio": "1.3.9",
"gdal": "3.8.4",
}
def resolved_versions() -> Dict[str, str]:
"""Collect the versions actually linked at runtime, not what pip claims to have installed."""
return {
"pyproj": pyproj.__version__,
"proj": pyproj.proj_version_str,
"rasterio": rasterio.__version__,
"gdal": gdal.__version__,
}
def assert_pinned(expected: Dict[str, str], actual: Dict[str, str]) -> None:
"""Raise RuntimeError listing every component whose resolved version differs from the pin."""
drift = {k: (expected[k], actual.get(k)) for k in expected if actual.get(k) != expected[k]}
if drift:
report = ", ".join(f"{k}: expected {w}, got {g}" for k, (w, g) in drift.items())
raise RuntimeError(f"Geospatial stack drift detected -> {report}")
logger.info("Version gate passed: %s", actual)
if __name__ == "__main__":
try:
assert_pinned(EXPECTED, resolved_versions())
except RuntimeError as exc:
logger.error("%s", exc)
sys.exit(1)
Wire it into the build so a mismatch stops the image from ever being tagged:
COPY check_versions.py /opt/check_versions.py
RUN python /opt/check_versions.py
Step 4 — Container-Start Self-Check With an Asserted Transform
A version gate proves the libraries match; it does not prove the grids produce the right number. Add a start-up self-check that runs a known transform and asserts the result to a tolerance, so a container with pruned or corrupt grids refuses to start rather than silently emitting shifted coordinates:
#!/usr/bin/env python3
"""Container-start self-check: print the stack and assert a known transform to tolerance."""
import logging
import sys
import pyproj
from pyproj import Transformer
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("startup-selfcheck")
# Golden fixture computed once in the pinned, fully-gridded image and committed to the repo.
EXPECTED_X: float = 456834.267100
EXPECTED_Y: float = 4262124.518900
TOLERANCE_M: float = 0.005 # 5 mm: tight enough to catch a grid fallback, loose for float noise
def self_check() -> None:
"""Log resolved versions and grid dir, then assert the golden transform within tolerance."""
logger.info("pyproj %s | PROJ %s | grids %s",
pyproj.__version__, pyproj.proj_version_str, pyproj.datadir.get_data_dir())
transformer = Transformer.from_crs("EPSG:6318", "EPSG:6347", always_xy=True)
x, y = transformer.transform(-75.0, 38.5)
dx, dy = abs(x - EXPECTED_X), abs(y - EXPECTED_Y)
logger.info("Transform result: %.6f, %.6f (dx=%.4f m, dy=%.4f m)", x, y, dx, dy)
if dx > TOLERANCE_M or dy > TOLERANCE_M:
raise SystemExit(
f"Transform drift {dx:.4f}/{dy:.4f} m exceeds {TOLERANCE_M} m — "
"grids missing or PROJ mismatch. Refusing to start."
)
logger.info("Self-check passed; pipeline may start.")
if __name__ == "__main__":
try:
self_check()
except SystemExit as exc:
logger.error("%s", exc)
sys.exit(1)
Set it as the entrypoint prelude so every task validates before doing work:
ENTRYPOINT ["sh", "-c", "python /opt/startup_selfcheck.py && exec \"$@\"", "--"]
Verification
Confirm determinism by building twice and diffing the self-check output, and by running the golden transform from a shell inside the container:
docker build -t marine-geo:pinned .
docker run --rm marine-geo:pinned python /opt/startup_selfcheck.py
# Expect: identical result lines across rebuilds and hosts, within 5 mm.
# Independently confirm the linked PROJ and the grid directory:
docker run --rm marine-geo:pinned python -c \
"import pyproj; print(pyproj.proj_version_str, pyproj.datadir.get_data_dir())"
# Expect: 9.3.1 /opt/proj
An acceptance run is green only when the version gate, the start-up self-check, and a two-host rebuild all agree on the numeric transform result to within the 5 mm tolerance.
Edge Cases and Gotchas
-
PROJ_NETWORKon versus off changes results, not just latency. With network on, a runtime CDN fetch can supply a grid that was absent at build, so two runs of the “same” image disagree. Pin itOFFand bake grids; only enable it in a controlled build stage that runsprojsync. -
conda-forge and PyPI wheels bundle different PROJ builds. A conda
gdaland a pippyprojcan each carry their own PROJ with different grid search paths, soPROJ_DATAmay be honoured by one and ignored by the other. Choose a single channel for the whole geospatial stack and verifypyproj.datadir.get_data_dir()equals the GDAL PROJ path. -
Deprecated EPSG/grid names silently no-op. Old
.gtx/.gsbfilenames and+datum=NAD83shorthands still parse under PROJ 9 but may resolve to a null transform instead of raising. Reference transformations by EPSG code and confirm the chosen pipeline withtransformer.descriptionrather than trusting a legacy grid name.
Related
- Reducing Geospatial Docker Image Size — sibling page; prune grids and layers without breaking the datum transforms pinned here
- Debugging Axis-Order Inversion in pyproj — the other classic silent CRS failure to guard against once versions are pinned
- CRS Alignment for Coastal GIS Projects — parent workflow for coastal reprojection correctness
- Geodetic Datums & EPSG Management — how to choose and manage the datum/EPSG pairs whose transforms you are stabilising