Pin four things together: the base image digest, the system GDAL and PROJ packages, the Python wheels that bind to them, and the datum grids on disk. Then assert all four at container start. Geospatial containers drift in a way most containers do not, because a PROJ upgrade can silently change where coordinates land — not whether the code runs.
This page is the pinning recipe. For image structure, size and layer strategy, see Containerizing Geospatial Inference Pipelines.
Why This Fails in Geospatial ML Pipelines
The dangerous failure is not a crash. PROJ chooses a transformation pipeline between two coordinate reference systems, and its choice depends on the library version and on which datum grids are present. Upgrade PROJ from 9.2 to 9.3 in an unpinned apt-get install and a transformation that previously used a Helmert approximation may now use a grid — moving every coordinate by one to two metres. On a 10 m raster that is a fifth of a pixel; on a 1 m lidar product it is two pixels, and every sample in the retrained model has shifted relative to the ones in the deployed model.
The second failure is the double stack. pip install rasterio fetches a manylinux wheel that bundles its own GDAL and PROJ. If the image also ran apt-get install libgdal-dev, two copies exist, and which one the process loads depends on LD_LIBRARY_PATH and the linker’s search order. Two builds of the same Dockerfile, months apart, can differ — and the symptom is a PROJ: proj_create_from_database error that appears on one node and not another.
Third, network-fetched grids. PROJ can download datum grids on demand from a CDN. That is convenient on a laptop and unacceptable in a pipeline: the transformation now depends on network reachability and on what the CDN served that day. The same run, repeated, is not guaranteed to produce the same coordinates.
Core Principles
- One source of GDAL/PROJ. Wheels or system libraries, never both.
- Reference the base image by digest, not by tag.
- Pin system packages to exact versions, and record them in the image.
- Bake the datum grids and set
PROJ_DATA; disable network fetching. - Assert the whole stack at start-up and fail closed.
- Include a coordinate golden test in CI — a known point transformed to a known result.
Production-Ready Code
# syntax=docker/dockerfile:1.7
# Base pinned by DIGEST — a tag is a moving target.
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.8.4@sha256:9f2c1b6a4e5d0c73f1a2b8e4c6d9a0f3b5e7c1d2a4f6b8c0d2e4f6a8b0c2d4e6
ENV DEBIAN_FRONTEND=noninteractive \
PROJ_DATA=/opt/proj \
PROJ_NETWORK=OFF \
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
GDAL_CACHEMAX=256 \
PYTHONDONTWRITEBYTECODE=1
# Exact system versions. `apt-get install libproj-dev` without a version is drift.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3-pip=23.0.1+dfsg-1 \
libproj25=9.3.1-1~jammy0 \
proj-data=9.3.1-1~jammy0 \
&& rm -rf /var/lib/apt/lists/*
# Bake the datum grids this pipeline needs, then forbid network fetching.
RUN mkdir -p /opt/proj \
&& cp -r /usr/share/proj/. /opt/proj/ \
&& projsync --target-dir /opt/proj --area-of-use "Europe" --system-directory
# Wheels built against the SYSTEM libraries — no bundled second copy.
COPY requirements.txt /tmp/
RUN pip install --no-cache-dir --no-binary rasterio,pyproj -r /tmp/requirements.txt
# Record what was built, so the runtime can compare against it.
RUN python3 -c "import json, rasterio, pyproj; \
open('/opt/stack.json','w').write(json.dumps({ \
'gdal': rasterio.__gdal_version__, \
'proj': pyproj.proj_version_str, \
'rasterio': rasterio.__version__, \
'pyproj': pyproj.__version__}))"
COPY geoml/ /app/geoml/
WORKDIR /app
ENTRYPOINT ["python3", "-m", "geoml.entrypoint"]# geoml/stack.py — imported first by the entrypoint.
from __future__ import annotations
import json
import logging
import os
logger = logging.getLogger(__name__)
def current_stack() -> dict:
import pyproj
import rasterio
return {"gdal": rasterio.__gdal_version__, "proj": pyproj.proj_version_str,
"rasterio": rasterio.__version__, "pyproj": pyproj.__version__}
def assert_stack(expected_path: str = "/opt/stack.json") -> dict:
"""Fail closed when the runtime stack differs from the build-time stack."""
with open(expected_path) as fh:
expected = json.load(fh)
actual = current_stack()
drift = {k: (expected[k], actual[k]) for k in expected if expected[k] != actual[k]}
if drift:
raise RuntimeError(
"geospatial stack drifted since build: "
+ ", ".join(f"{k}: built {b}, running {r}" for k, (b, r) in drift.items()))
proj_data = os.environ.get("PROJ_DATA")
if not proj_data or not os.path.isdir(proj_data):
raise RuntimeError(f"PROJ_DATA is not a directory: {proj_data!r}")
if os.environ.get("PROJ_NETWORK", "OFF").upper() != "OFF":
raise RuntimeError("PROJ_NETWORK must be OFF for reproducible transformations")
logger.info("stack ok: GDAL %s, PROJ %s", actual["gdal"], actual["proj"])
return actual
def transform_fingerprint(src: str = "EPSG:4326", dst: str = "EPSG:25832") -> str:
"""The transformation PROJ selects — the thing that actually moves coordinates."""
from pyproj import Transformer
return Transformer.from_crs(src, dst, always_xy=True).descriptionStep-by-Step Walkthrough
Step 1 — Discover the exact versions before pinning them
docker run --rm ghcr.io/osgeo/gdal:ubuntu-small-3.8.4 bash -lc \
'apt-cache policy libproj25 proj-data | grep -E "Installed|Candidate"'Copy those strings into the apt-get install line verbatim. A pin that says libproj25 with no version pins nothing.
Step 2 — Choose one library source and prove it
docker run --rm geoml-inference:2026.08 bash -lc \
'python3 -c "import rasterio; print(rasterio.__file__)"; ldd $(python3 -c "
import rasterio, pathlib; print(list(pathlib.Path(rasterio.__file__).parent.glob(\"*.so\"))[0])
") | grep -E "gdal|proj"'Every .so should resolve to /usr/lib, not to a path inside site-packages/rasterio.libs. If both appear, the image has two stacks.
Step 3 — Record the transformation, not just the version
from geoml.stack import transform_fingerprint
print(transform_fingerprint("EPSG:4326", "EPSG:25832"))
# "Inverse of ETRS89 to WGS 84 (1) + UTM zone 32N"The description names the pipeline PROJ selected. Store it in the model card next to the library versions — it is the highest-signal single string for reproducibility, and it changes when the grids change even if the versions do not.
Step 4 — Assert at start-up
# geoml/entrypoint.py
from geoml.stack import assert_stack
def main() -> None:
stack = assert_stack()
logger.info("starting inference with %s", stack)
run_pipeline()Failing closed is the point. A container that starts with the wrong PROJ produces plausible, subtly wrong coordinates for hours before anyone notices.
Step 5 — Keep a coordinate golden test in CI
import pytest
from pyproj import Transformer
def test_known_point_transforms_to_known_coordinates():
"""Frankfurt: EPSG:4326 -> EPSG:25832, to the millimetre."""
t = Transformer.from_crs("EPSG:4326", "EPSG:25832", always_xy=True)
x, y = t.transform(8.682127, 50.110924)
assert x == pytest.approx(476_487.334, abs=0.001)
assert y == pytest.approx(5_551_320.481, abs=0.001)This is the test that catches a PROJ change no version comparison would flag — for instance, a grid added or removed in the image without a library upgrade.
Verification
import json
import os
import pytest
def test_stack_matches_build_manifest():
assert_stack("/opt/stack.json")
def test_only_one_gdal_is_loaded():
"""A bundled wheel library alongside a system one is the classic double stack."""
import subprocess, pathlib, rasterio
so = next(pathlib.Path(rasterio.__file__).parent.glob("*.so"))
out = subprocess.run(["ldd", str(so)], capture_output=True, text=True).stdout
libs = [ln for ln in out.splitlines() if "libgdal" in ln]
assert len(libs) == 1, f"multiple GDAL libraries linked:\n{out}"
assert "site-packages" not in libs[0], "wheel-bundled GDAL is being used"
def test_proj_network_is_disabled():
assert os.environ.get("PROJ_NETWORK", "OFF").upper() == "OFF"
def test_proj_data_contains_the_grids():
files = os.listdir(os.environ["PROJ_DATA"])
assert "proj.db" in files
assert any(f.endswith(".tif") for f in files), "no datum grids baked into the image"
def test_transformation_pipeline_is_the_expected_one():
from geoml.stack import transform_fingerprint
expected = json.load(open("/opt/stack.json")).get("transform_4326_25832")
if expected:
assert transform_fingerprint("EPSG:4326", "EPSG:25832") == expectedRun these as the container’s health check as well as in CI. A health check that only reports “process alive” tells you nothing about whether the coordinates are right.
FAQ
Why do rasterio wheels and a system GDAL conflict?
Manylinux wheels bundle their own GDAL and PROJ. With a system copy also present, which one loads depends on the linker search order, so identical Dockerfiles can behave differently. Pick one source and verify with ldd, as in Step 2.
What actually changes when PROJ changes version?
The transformation pipeline it selects between two CRSs, which depends on the version and the installed grids. A minor upgrade can switch from a Helmert approximation to a grid and move coordinates by one to two metres — with no error at all.
Should datum grids be baked into the image?
Yes. The alternative is PROJ’s network fetching, which makes the transformation depend on what a CDN served at run time. Bake them, point PROJ_DATA at them, and set PROJ_NETWORK=OFF.
Related
- Containerizing Geospatial Inference Pipelines — image structure and size
- Building a Slim GDAL Docker Image for Inference — trimming the image once it is pinned
- CRS Alignment and Projection Handling — what these transformations are doing
- Fixing Opset and Dynamic Shape Errors During ONNX Export — the other version constraint this image imposes
Part of: Containerizing Geospatial Inference Pipelines Part of: Geospatial MLOps and Model Deployment