Pinning GDAL, PROJ and OpenCV Versions Reproducibly
A survey is reprocessed a year after the original run, from the same imagery, with the same parameters, in the same container image, and the coordinates come out eleven centimetres different. Nothing in the pipeline changed. The base image’s tag was rebuilt by its publisher six months ago against a newer PROJ, which ships a revised transformation grid for that region.
Reproducibility in a geospatial pipeline is mostly a question of what “the same” means, and a tag does not mean it. This page covers pinning the stack so that “same image, same input, same answer” actually holds. It is the version-control detail behind containerising photogrammetry workers with Docker.
Four things that move under a fixed tag
The base image. gdal:3.8.0 identifies a build, and the publisher can push a new build under the same tag. The version number is unchanged and the binaries are not.
Transitive Python dependencies. pip install rasterio==1.3.9 pins rasterio and not the twelve packages it depends on, several of which release weekly.
System packages. apt-get install pdal installs whatever the distribution’s archive holds today, which changes with every security update.
PROJ grid data. Transformation grids are versioned separately from PROJ itself and are revised as national agencies publish improvements. A newer grid is more accurate and produces different coordinates.
The last is the one that surprises people, because it is the only one where the change is an improvement and the result is still a reproducibility failure.
Figure 1 — Four levels of pinning, and what each leaves free to move.
Minimal reproducible solution
# 1. The base, by digest. The tag is a comment, not a reference.
FROM ghcr.io/osgeo/gdal@sha256:0f2a1c2b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8
# gdal:ubuntu-small-3.8.0 as of 2026-01-14
# 2. System packages with explicit versions. An unpinned apt install is a
# different pipeline every time the archive updates.
RUN apt-get update && apt-get install -y --no-install-recommends \
pdal=2.6.0+ds-1 \
libpdal-base14=2.6.0+ds-1 \
&& rm -rf /var/lib/apt/lists/*
# 3. Python from a fully resolved lockfile, hashes included.
COPY requirements.lock /tmp/requirements.lock
RUN pip install --no-cache-dir --require-hashes -r /tmp/requirements.lock
# 4. PROJ grids baked in and network fetching disabled, so a transformation
# cannot silently improve between runs.
COPY proj-data/ /usr/share/proj/
ENV PROJ_NETWORK=OFF PROJ_DATA=/usr/share/proj
--require-hashes is what turns a lockfile from a convention into an enforcement. Without it, a lockfile pins versions and a compromised or re-released artefact with the same version number still installs; with it, the bytes must match.
Generating and maintaining the lockfile
import subprocess
from pathlib import Path
def regenerate_lockfile(requirements_in: str, lockfile: str,
*, image: str) -> dict:
"""Resolve dependencies inside the target image, not on a developer machine.
Resolution is platform-dependent: a wheel chosen on macOS may not exist
for the image's Linux and Python version, and the difference surfaces as
a build failure weeks later. Resolving inside the image removes it.
"""
result = subprocess.run(
["docker", "run", "--rm", "-v", f"{Path(requirements_in).parent}:/w",
"-w", "/w", image,
"sh", "-c", f"pip install pip-tools && pip-compile --generate-hashes "
f"--output-file {Path(lockfile).name} {Path(requirements_in).name}"],
capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(result.stderr[-600:])
return {"lockfile": lockfile, "lines": len(Path(lockfile).read_text().splitlines())}
Regenerating deliberately, on a schedule, is the counterpart to pinning hard. A pipeline that never updates accumulates security debt; one that updates on every build is not reproducible. The practice that works is pinning everything, updating on a cadence, and re-running a reference dataset after each update to see what moved.
Figure 3 — Three levels, and only the third is a guarantee.
Edge-case matrix
| Situation | Risk | Handling |
|---|---|---|
| Base referenced by tag | Rebuilt under the same name | Reference by digest |
pip install package==x |
Transitive deps float | Full lockfile with hashes |
apt-get install pkg |
Archive moves | Pin versions, or snapshot the archive |
| PROJ network fetching on | Grids appear and improve | Bake grids in, PROJ_NETWORK=OFF |
| Conda environment | Solver picks new builds | Export an explicit environment with builds |
| Registry garbage collection | Old digests deleted | Retain images for the archive period |
| Distribution end of life | Package versions unavailable | Snapshot or vendor the packages |
| GPU driver on the host | Outside the image entirely | Record it in the run manifest |
The GPU driver row is worth stating because it cannot be fixed by any amount of image pinning. The driver lives on the host, the container uses it, and it therefore belongs in the run record alongside the image digest rather than in the image.
Verification snippet
import json
import subprocess
PROBE = """
import hashlib, json, os, sys
out = {"python": sys.version.split()[0]}
try:
from osgeo import gdal
out["gdal"] = gdal.__version__
except Exception as e:
out["gdal"] = f"error: {e}"
try:
import pyproj
out["proj"] = pyproj.proj_version_str
d = pyproj.datadir.get_data_dir()
out["proj_data_dir"] = d
names = sorted(f for f in os.listdir(d) if f.endswith((".tif", ".gsb")))
h = hashlib.sha256()
for n in names:
h.update(n.encode())
h.update(str(os.path.getsize(os.path.join(d, n))).encode())
out["proj_grid_digest"] = h.hexdigest()[:16]
out["proj_grid_count"] = len(names)
except Exception as e:
out["proj"] = f"error: {e}"
try:
import cv2
out["opencv"] = cv2.__version__
except Exception as e:
out["opencv"] = f"error: {e}"
print(json.dumps(out))
"""
def stack_fingerprint(image: str) -> dict:
"""A single record identifying every component that can change an answer."""
result = subprocess.run(["docker", "run", "--rm", image, "python3", "-c", PROBE],
capture_output=True, text=True, check=True)
fingerprint = json.loads(result.stdout)
fingerprint["image_digest"] = subprocess.run(
["docker", "inspect", "--format", "{{index .RepoDigests 0}}", image],
capture_output=True, text=True, check=True).stdout.strip()
return fingerprint
Hashing the grid inventory rather than the grid contents is a deliberate compromise: it is fast, it catches a grid appearing, disappearing or being replaced by a different-sized file, and it does not require reading gigabytes. For most pipelines that is the right balance.
Figure 2 — The change that happens because somebody improved something.
Updating without losing reproducibility
Pinning hard and never updating is not a strategy; it is deferred maintenance with a security dimension. The practice that keeps both properties is to make updates discrete, dated events with a measured consequence.
Update on a cadence, not on a build. Monthly or quarterly, depending on the programme. Between updates the stack is frozen and every result is comparable.
Re-run a reference dataset after every update. A small survey kept for this purpose, processed before and after, gives a concrete number for what the update changed: coordinates moved by so many millimetres, this many points differ, the runtime changed by this much. That number is worth far more than a changelog.
Record the update as a version of the pipeline. Results produced before and after are comparable within each period and carry a documented step between them, which is exactly how a monitoring series should treat any methodological change.
def reference_comparison(before: dict, after: dict) -> dict:
"""What an update changed, measured on a reference dataset."""
shifts = [abs(before["points"][k] - after["points"][k]) for k in before["points"]
if k in after["points"]]
return {"points_compared": len(shifts),
"max_shift_m": max(shifts) if shifts else 0.0,
"median_shift_m": sorted(shifts)[len(shifts) // 2] if shifts else 0.0,
"runtime_change": after["runtime_s"] / max(before["runtime_s"], 1e-9),
"material": bool(shifts and max(shifts) > 0.01)}
A material flag on that comparison is what decides whether the update needs to be announced to clients or can simply be recorded. Most updates move nothing measurable; the ones that do are exactly the ones worth a sentence in the next report.
When to escalate
- An old image digest is no longer in the registry. The result cannot be reproduced exactly. Record the fingerprint with every run so at least the components are known, and set a retention policy that matches the archive period.
- A transformation grid must be updated for accuracy. Do it deliberately, re-run a reference dataset, and record the step in the project history. The change is legitimate and the discontinuity must be documented.
- Bit-identical output is contractually required. Pin the thread count and the hardware as well; floating-point reduction order depends on both, and no image pins either.