Containerizing Geospatial Inference Pipelines

Containerize geospatial inference in Docker: pin GDAL/PROJ, set PROJ_LIB and GDAL_DATA, and build slim, reproducible multi-stage images for tile-in inference.

A geospatial inference pipeline is only as reproducible as the native libraries underneath it. Unlike a pure-Python model that depends on a handful of manylinux wheels, a raster inference job pulls in GDAL, PROJ, and GEOS — compiled C/C++ libraries with their own on-disk support databases, driver plugins, and strict ABI expectations. Get one version wrong and the model that scored perfectly in your notebook throws PROJ: proj_create: Cannot find proj.db the moment it lands on a production node. Containerizing the pipeline is how you freeze that native stack into a single, versioned, tile-in tile-out artifact.

This is part of Geospatial MLOps and Model Deployment, which covers the full path from a trained model to monitored production inference. This guide focuses on the packaging step: why geospatial images are fragile and heavy, how to build a slim multi-stage image that pins the GDAL stack, how to wire PROJ_LIB and GDAL_DATA correctly, and how to verify that a container preserves the coordinate reference system and array shape of every tile it processes.

Problem Framing

Three properties of the geospatial stack make naive containerization fail.

Native dependencies with runtime data files. GDAL ships with a GDAL_DATA directory of coordinate system and format support files; PROJ ships with proj.db, a SQLite database of every projection and datum transformation. These are not Python packages — they are files GDAL and PROJ locate at runtime through the GDAL_DATA and PROJ_LIB environment variables. A multi-stage build that copies Python site-packages but forgets these directories produces an image that imports rasterio cleanly and then fails on the first reprojection.

Version skew between training and inference. A model trained against rasterio==1.3.10 (GDAL 3.8) can silently change behaviour under a different GDAL build: nodata handling, resampling kernels, and default block sizes have all shifted between GDAL minor versions. If the inference container installs “latest,” you have introduced an untracked variable between the environment that produced your validation metrics and the environment that serves users. The container’s job is to eliminate that gap — the exact same reason you version the model weights and, upstream, version the training data with DVC and lakeFS.

Image bloat. A libgdal-dev install plus a full geospatial Python stack, build toolchain, and every GDAL driver plugin easily reaches 3 GB. Large images slow every cold start, inflate registry costs, and widen the attack surface. The remedy — a multi-stage build that discards compilers and dev headers — is the backbone of this guide and is explored end-to-end in building a slim GDAL Docker image for inference.

The target architecture is a builder stage that compiles and installs everything, and a lean runtime stage that carries only the installed libraries, their support databases, the model artifact, and the entrypoint.

Multi-stage Docker build to a tile-in / predictions-out geospatial runtime The builder stage installs build tools and compiles GDAL, PROJ, GEOS, rasterio and onnxruntime. A COPY step carries only the installed site-packages and the PROJ and GDAL data directories into a slim runtime stage. The runtime stage also receives the ONNX model artifact and sets PROJ_LIB and GDAL_DATA. An input tile enters the inference boundary and a georeferenced predictions raster leaves it. Stage 1 — builder compilers + dev headers (discarded) build-essential · gcc · make GDAL · PROJ · GEOS native C/C++ libraries rasterio · geopandas · pyproj onnxruntime (pinned) proj.db · GDAL_DATA files /opt/model/model.onnx COPY --from=builder Stage 2 — slim runtime no compilers · no dev headers GDAL · PROJ · GEOS + wheels runtime shared objects only PROJ_LIB · GDAL_DATA set env points at copied data dirs model.onnx + entrypoint.py inference boundary input tile GeoTIFF predictions CRS preserved image < 700 MB

Prerequisites & Environment Setup

You need Docker (or a compatible builder such as BuildKit or Podman) and a trained model exported to a portable format. This guide assumes the model is an ONNX graph, produced as described in ONNX export for geospatial model inference. ONNX decouples inference from the training framework, so the container ships onnxruntime instead of the full PyTorch or XGBoost stack — a large image-size win.

Pin every geospatial dependency to an exact version so the runtime matches the training environment byte for byte.

# requirements.lock — pinned inference stack
rasterio==1.3.10
rioxarray==0.15.5
geopandas==0.14.4
pyproj==3.6.1
shapely==2.0.5
numpy==1.26.4
onnxruntime==1.18.1

Two viable base-image strategies exist, and they must not be mixed:

  • System-GDAL base — start from a versioned ghcr.io/osgeo/gdal tag, which provides libgdal, PROJ, and their data directories already installed and correctly wired. Install Python packages with --no-binary rasterio so rasterio links against the system libgdal rather than bundling its own.
  • Conda-forge base — start from mambaorg/micromamba and install the whole stack from conda-forge, which ships pre-built, mutually compatible GDAL, PROJ, GEOS, and Python bindings. This avoids compilation entirely at the cost of a slightly larger base.

Both produce reproducible runtimes. The examples below use the system-GDAL base because it yields the smallest final image once the builder stage is discarded.

Verify your host toolchain

docker --version                       # 24.x or newer for BuildKit defaults
docker buildx version                  # multi-stage + cache mounts
python -c "import onnx; print(onnx.__version__)"

Confirm the ONNX opset the model was exported with; the onnxruntime version in requirements.lock must support that opset or inference will refuse to load the graph.

Step-by-Step Implementation

Step 1 — Pin the base image and the GDAL stack

Choose an immutable, digest-pinned base tag. Never use latest — it reintroduces the exact version skew containerization is meant to remove.

# Pin to a specific GDAL release, not a floating tag.
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.8.5 AS builder

ENV DEBIAN_FRONTEND=noninteractive \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Build tools live only in the builder stage.
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential \
        python3-dev \
        python3-pip \
        python3-venv \
    && rm -rf /var/lib/apt/lists/*

Creating an isolated virtual environment inside the builder makes the copy step in Step 2 trivial: you copy one self-contained directory rather than hunting for packages scattered across the system Python.

# Isolated environment so the runtime copy is a single directory.
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.lock /tmp/requirements.lock
# --no-binary rasterio forces a link against the system libgdal in this base.
RUN pip install --no-binary rasterio -r /tmp/requirements.lock

Step 2 — Write the multi-stage build

The second stage starts from the same GDAL base but installs no compilers. It receives the virtual environment and, critically, the PROJ and GDAL data directories.

# ---- Stage 2: slim runtime ----
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.8.5 AS runtime

# Copy only the installed environment from the builder.
COPY --from=builder /opt/venv /opt/venv

# Copy the model artifact produced by ONNX export.
COPY model.onnx /opt/model/model.onnx
COPY entrypoint.py /opt/app/entrypoint.py

ENV PATH="/opt/venv/bin:$PATH"

Because both stages share the same GDAL base, proj.db and the GDAL_DATA files already exist in the runtime image at the base’s default locations — you do not need to copy them out of the builder. If you had compiled GDAL from source in the builder, you would add explicit COPY --from=builder /usr/share/proj /usr/share/proj and COPY --from=builder /usr/share/gdal /usr/share/gdal lines here.

Step 3 — Set PROJ_LIB and GDAL_DATA

Make the support-database locations explicit rather than relying on defaults. Explicit environment variables survive base-image upgrades and make debugging obvious.

# Point PROJ and GDAL at their support databases explicitly.
ENV PROJ_LIB=/usr/share/proj \
    GDAL_DATA=/usr/share/gdal \
    PROJ_NETWORK=OFF

# Fail the build early if the databases are missing.
RUN test -f "${PROJ_LIB}/proj.db" || (echo "proj.db missing at ${PROJ_LIB}" && exit 1) \
    && python -c "import rasterio, pyproj; pyproj.CRS.from_epsg(32633)"

Setting PROJ_NETWORK=OFF keeps the container hermetic: PROJ will not attempt to fetch transformation grids over the network at runtime, which would make inference non-deterministic and dependent on outbound connectivity. The build-time python -c smoke test resolves a projected CRS, so a broken PROJ configuration fails the build instead of the first production request. Correct handling of coordinate systems inside the container follows the same rules covered in CRS alignment and projection handling.

Step 4 — Write the inference entrypoint

The entrypoint reads a single input tile, runs the ONNX model, and writes a georeferenced prediction raster. The essential contract is that the output preserves the input’s CRS and affine transform — the prediction grid must line up with the pixels it was computed from.

"""entrypoint.py — tile-in / predictions-out geospatial inference."""
from __future__ import annotations

import argparse
import sys

import numpy as np
import onnxruntime as ort
import rasterio


def run_inference(input_tile: str, output_tile: str, model_path: str) -> None:
    """Read a raster tile, run the ONNX model, and write a prediction raster.

    Args:
        input_tile: Path to the input GeoTIFF (bands = model input channels).
        output_tile: Path for the single-band prediction GeoTIFF.
        model_path: Path to the ONNX model artifact.
    """
    with rasterio.open(input_tile) as src:
        if src.crs is None:
            raise ValueError(f"{input_tile} has no CRS; cannot georeference output")
        profile = src.profile
        # Shape: (bands, rows, cols) -> model expects (N, C, H, W)
        bands = src.read().astype("float32")

    n_bands, height, width = bands.shape
    batch = bands[np.newaxis, ...]  # add batch dimension

    session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
    input_name = session.get_inputs()[0].name
    logits = session.run(None, {input_name: batch})[0]  # (1, classes, H, W)

    predictions = np.argmax(logits, axis=1).astype("uint8")[0]  # (H, W)
    if predictions.shape != (height, width):
        raise ValueError(
            f"Prediction shape {predictions.shape} != input grid {(height, width)}"
        )

    # Preserve CRS and transform; single-band uint8 output.
    profile.update(count=1, dtype="uint8", nodata=255, compress="deflate")
    with rasterio.open(output_tile, "w", **profile) as dst:
        dst.write(predictions, 1)


def main() -> int:
    parser = argparse.ArgumentParser(description="Geospatial ONNX inference")
    parser.add_argument("--input", required=True)
    parser.add_argument("--output", required=True)
    parser.add_argument("--model", default="/opt/model/model.onnx")
    args = parser.parse_args()
    run_inference(args.input, args.output, args.model)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Wire the entrypoint into the image so the container runs inference by default:

WORKDIR /opt/app
ENTRYPOINT ["python", "entrypoint.py"]
CMD ["--help"]

Step 5 — Add a healthcheck

A healthcheck lets an orchestrator confirm the native stack is importable before routing traffic. Import the libraries and resolve a CRS — the two operations most likely to fail from a broken build.

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD python -c "import rasterio, onnxruntime, pyproj; \
pyproj.CRS.from_epsg(4326); print('ok')" || exit 1

Build the image, tagging it with the resolved GDAL version so the running container is self-describing:

docker build -t geo-inference:gdal3.8.5-onnx1.18 .
docker image ls geo-inference:gdal3.8.5-onnx1.18

Verification & Testing

Building is not enough — verify the container produces a georeferenced output whose CRS and shape match the input. Run it against a sample tile:

docker run --rm \
    -v "$(pwd)/samples:/data" \
    geo-inference:gdal3.8.5-onnx1.18 \
    --input /data/sample_tile.tif \
    --output /data/prediction.tif

Then assert the geospatial contract held. This test is what turns “the container ran” into “the container is correct”:

import rasterio

with rasterio.open("samples/sample_tile.tif") as src_in:
    in_crs, in_shape, in_transform = src_in.crs, (src_in.height, src_in.width), src_in.transform

with rasterio.open("samples/prediction.tif") as src_out:
    assert src_out.crs == in_crs, f"CRS drift: {src_out.crs} != {in_crs}"
    assert (src_out.height, src_out.width) == in_shape, "Prediction grid shape changed"
    assert src_out.transform == in_transform, "Affine transform not preserved"
    assert src_out.count == 1, "Expected a single prediction band"

print("Container output preserves CRS, transform, and grid shape")

Add this as a CI step that runs on every image build. A container that passes unit tests but silently reprojects or resamples its output is worse than one that crashes, because the error is invisible until a downstream map is misaligned. Once the container is verified, monitoring its predictions over time is the job of model drift detection for geospatial inference.

Confirm the image also honours its own healthcheck:

docker run -d --name geo-hc geo-inference:gdal3.8.5-onnx1.18 sleep 60
sleep 5
docker inspect --format '{{.State.Health.Status}}' geo-hc   # -> healthy
docker rm -f geo-hc

Troubleshooting & Common Errors

PROJ: proj_create: Cannot find proj.db / “PROJ database context is not initialized”

Cause: PROJ cannot locate proj.db. Either PROJ_LIB points at the wrong directory or the database was never copied into the runtime stage of a from-source build.

Fix: Set PROJ_LIB to the directory that actually contains proj.db and verify it exists in the final image:

docker run --rm geo-inference:gdal3.8.5-onnx1.18 \
    bash -c 'echo $PROJ_LIB && ls -l $PROJ_LIB/proj.db'

If the file is absent, add COPY --from=builder /usr/share/proj /usr/share/proj to the runtime stage.

GDAL_DATA not found — CRS and format lookups fail

Cause: GDAL’s support directory was not carried into the runtime image, so datum and format definitions cannot be resolved.

Fix: Set GDAL_DATA explicitly and confirm the directory is populated. gdal-config --datadir inside the builder prints the correct source path to copy from:

docker run --rm geo-inference:gdal3.8.5-onnx1.18 \
    bash -c 'ls $GDAL_DATA | head'

Image is 3 GB instead of under 700 MB

Cause: Build tools (build-essential, python3-dev) and apt caches leaked into the final image, usually because the build is single-stage or the runtime stage reinstalls dev packages.

Fix: Keep all compilers in the builder stage only, copy just /opt/venv into the runtime, and never run apt-get install build-essential in the runtime stage. Combine apt-get update, install, and rm -rf /var/lib/apt/lists/* in one RUN so the cache never becomes a layer.

Wheel vs system GDAL ABI mismatch — segfault or undefined symbol

Cause: A rasterio or fiona wheel bundles its own libgdal, which collides with the system libgdal from the base image. The Python binding links one version, the process loads another.

Fix: Commit to one source. On a GDAL system base, install with pip install --no-binary rasterio,fiona so the bindings link the system library. On a wheel-only strategy, do not install libgdal-dev at all and let the wheels supply GDAL. Confirm the linkage:

docker run --rm geo-inference:gdal3.8.5-onnx1.18 \
    python -c "import rasterio; print(rasterio.__gdal_version__)"

ImportError: libexpat.so.1: cannot open shared object file

Cause: A minimal runtime base is missing a transitive shared library that GDAL drivers (KML, GPX, GML) depend on. libexpat, libsqlite3, and libtiff are common casualties of over-aggressive slimming.

Fix: Install the specific runtime library — not the -dev package — in the runtime stage:

RUN apt-get update && apt-get install -y --no-install-recommends \
        libexpat1 libsqlite3-0 \
    && rm -rf /var/lib/apt/lists/*

Fatal error: onnxruntime cannot load the model — opset mismatch

Cause: The onnxruntime version in the image is older than the opset the model was exported with.

Fix: Align the versions. Either re-export the model to a lower opset or pin a newer onnxruntime in requirements.lock. Print the model’s opset to confirm the target:

python -c "import onnx; m=onnx.load('model.onnx'); print(m.opset_import)"

Performance Optimisation

Shrink the image with a disciplined runtime stage

The single biggest lever is never letting the toolchain reach the runtime. Beyond multi-stage separation, strip GDAL driver plugins you do not use and delete Python bytecode caches:

RUN find /opt/venv -name '__pycache__' -type d -prune -exec rm -rf {} + \
    && find /opt/venv -name '*.pyc' -delete

If the pipeline only ever reads GeoTIFF and writes GeoTIFF, you can also set GDAL_SKIP to disable unused drivers at runtime, reducing startup cost. The full slimming workflow — measuring each layer and trimming drivers — is covered in building a slim GDAL Docker image for inference.

Order layers for cache reuse

Docker caches layers top-down and invalidates everything after the first change. Copy the dependency lock file and install packages before copying application code, so editing entrypoint.py does not trigger a full GDAL reinstall:

COPY requirements.lock /tmp/requirements.lock
RUN pip install --no-binary rasterio -r /tmp/requirements.lock   # cached
COPY entrypoint.py /opt/app/entrypoint.py                        # cheap to rebuild

With BuildKit, add a cache mount so pip’s download cache persists across builds even when the lock file changes:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-binary rasterio -r /tmp/requirements.lock

Prefer wheels when compilation dominates build time

If build minutes matter more than the last hundred megabytes, a conda-forge or manylinux-wheel strategy skips compiling GDAL bindings entirely. The trade-off is a slightly larger base against dramatically faster, more reproducible builds — a good default for CI pipelines that rebuild the image on every commit.

Warm the ONNX session once, not per tile

Loading an InferenceSession is expensive. When the container processes many tiles in one invocation, construct the session once and reuse it, rather than reloading the model for every tile as the single-shot entrypoint above does. For batch jobs, refactor run_inference to accept a pre-built session.

FAQ

Why is my geospatial Docker image several gigabytes?

Most bloat comes from three sources: the build toolchain (build-essential, python3-dev), GDAL driver plugins you never use, and duplicated or cached layers. A multi-stage build that compiles in a builder stage and copies only the installed site-packages and the model artifact into a slim runtime typically drops an image from 3 GB to under 700 MB. Combine apt commands into a single RUN and delete __pycache__ directories to reclaim the remainder.

Should I install GDAL from wheels or from the system package manager?

Pick one and never mix them within an image. Mixing a system libgdal with a rasterio wheel that bundles its own libgdal causes ABI mismatches and segmentation faults. Either build from a GDAL system base and install rasterio with --no-binary, or use manylinux/conda wheels exclusively and do not install libgdal-dev. Both are reproducible; consistency is what matters.

How do I keep the container’s library versions identical to training?

Pin every geospatial and ML dependency to an exact version in a lock file, export the ONNX opset used at training time, and rebuild the image from that lock file. Tag the image and record the resolved GDAL and onnxruntime versions as image labels so a running container reports the exact stack it was built with. Upstream, pair this with dataset versioning so the whole pipeline — data, model, and runtime — is reproducible together.

Can I run the same image for CPU and GPU inference?

Not without changes. onnxruntime and onnxruntime-gpu are different packages with different native dependencies, and GPU inference needs a CUDA-enabled base image. Maintain two build targets from a shared Dockerfile using build arguments: a CPU stage on the standard GDAL base and a GPU stage on a CUDA-plus-GDAL base, each installing the matching onnxruntime variant.


Part of: Geospatial MLOps and Model Deployment