Building a Slim GDAL Docker Image for Inference

Build a small, reproducible GDAL Docker image for geospatial model inference using multi-stage builds, slim base images, pinned wheels, and cache purging.

TL;DR Answer

Split the build into two stages: a builder that installs the rasterio and pyproj binary wheels into a virtual environment, and a python:3.12-slim runtime that copies only that virtual environment. Because the wheels bundle their own GDAL, PROJ, and GEOS, you never install libgdal-dev or a compiler in the shipped image. Add --no-install-recommends, purge apt lists, pin every version, ship a .dockerignore, and set PROJ_NETWORK=OFF. This takes a naive rasterio image from roughly 3 GB down to a few hundred megabytes. This is the concrete recipe behind the broader Containerizing Geospatial Inference Pipelines workflow.

FROM python:3.12-slim AS build
ENV PIP_NO_CACHE_DIR=1
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" PROJ_NETWORK=OFF
ENTRYPOINT ["python", "-m", "inference"]

Part of: Containerizing Geospatial Inference Pipelines


Why GDAL Images Get Huge

A geospatial inference container carries a heavy native dependency: GDAL, along with PROJ for coordinate transforms and GEOS for geometry operations. The naive path is to start from a full Debian or Ubuntu base, apt-get install gdal-bin libgdal-dev, then pip install rasterio compiled against that system GDAL. The result routinely weighs 2.5–3.5 GB. That bloat is not the model weights or your Python code — it is toolchain and metadata that inference never uses.

Three things dominate the size:

  • Build toolchains. libgdal-dev, gcc, g++, make, and the Python dev headers exist only to compile C extensions. Once the wheel is built they are dead weight, but in a single-stage build they ship to production.
  • Recommended packages. By default apt pulls in “recommended” extras — documentation, X11 libraries, sample datasets — that a headless container will never load.
  • Cached metadata. /var/lib/apt/lists, pip’s HTTP cache, and __pycache__ directories add hundreds of megabytes if they land in a persisted layer.

The parent guide on Containerizing Geospatial Inference Pipelines covers container structure and orchestration broadly; this page is the concrete how-to for shrinking the image itself so it pulls fast and starts cold quickly.


The Key Decision: System libgdal vs Binary Wheels

There are two supported ways to get GDAL into a Python image, and choosing correctly is the single largest factor in final size.

System libgdal. You install libgdal-dev via apt and build rasterio against it. This is the right call when you need a bleeding-edge GDAL, a driver compiled with a proprietary dependency, or exact version parity with other system tools. The cost is that the dev package and its transitive build dependencies are large, and matching the pip-installed rasterio version to the apt GDAL version is fragile across base-image updates.

Binary wheels. Since the manylinux wheel format matured, pip install rasterio pyproj downloads prebuilt wheels that bundle their own copies of GDAL, PROJ, GEOS, and their dependencies inside the package. No system GDAL, no compiler, no dev headers. For an inference container that only needs to open rasters, run transforms, and read tiles, this is almost always the correct choice: smaller, faster to build, and reproducible because the wheel pins the exact native library versions.

The recipe below uses bundled wheels. The one operational caveat is PROJ data: because the wheel ships its own PROJ grids, you want offline transforms to be deterministic, which is where PROJ_NETWORK=OFF comes in. Getting transforms right also depends on your upstream data hygiene, covered in CRS Alignment and Projection Handling.


Anatomy of the Multi-Stage Build

The diagram below shows how a multi-stage build separates the heavy build environment from the lean runtime, and where each size saving is realised.

Multi-stage build: heavy builder versus slim runtime Two stacked boxes. The builder stage on the left installs wheels and build tools and is large. An arrow copies only the virtual environment to the runtime stage on the right, which is small. Discarded layers are shown falling away from the builder. Stage 1: build python:3.12-slim pip install rasterio pyproj wheels bundle GDAL/PROJ venv at /opt/venv pip cache — discarded apt lists — discarded COPY --from=build /opt/venv only Stage 2: runtime python:3.12-slim /opt/venv + app code no compiler, no dev headers ~1.6 GB (not shipped) ~380 MB (shipped) Only the final stage becomes the published image.

Only the last FROM produces the tagged image. Everything the builder stage installs — compilers, caches, intermediate wheels — is left behind unless you explicitly COPY --from it. That single property is what makes multi-stage builds the foundation of a slim GDAL image.


The Complete Slim Dockerfile

Pin your dependencies in a requirements.txt first. Pinning is not optional for reproducibility: an unpinned rasterio will silently pull a newer bundled GDAL on the next rebuild.

rasterio==1.4.3
pyproj==3.7.1
numpy==2.2.4
onnxruntime==1.22.0

Here is the full two-stage Dockerfile.

# syntax=docker/dockerfile:1

########################  Stage 1: builder  ########################
FROM python:3.12-slim AS build

# Fail fast, no interactive prompts, no pip cache in the layer.
ENV PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PYTHONDONTWRITEBYTECODE=1

# Isolated environment we can copy wholesale into the runtime stage.
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Install pinned deps. rasterio and pyproj wheels bundle GDAL/PROJ/GEOS,
# so no system libgdal-dev or compiler is required here.
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir -r requirements.txt

########################  Stage 2: runtime  ########################
FROM python:3.12-slim

# Non-root user for the inference process.
RUN groupadd --system app && useradd --system --gid app --home /app app

# Only the built virtual environment crosses the stage boundary.
COPY --from=build /opt/venv /opt/venv

# Application code (kept small by .dockerignore).
WORKDIR /app
COPY --chown=app:app src/ ./src/

ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    # Force deterministic, offline coordinate transforms.
    PROJ_NETWORK=OFF

USER app
ENTRYPOINT ["python", "-m", "src.inference"]

If a driver you need is genuinely missing from the bundled GDAL, add the system library — but only the runtime shared object, never the -dev package, and purge apt lists in the same layer:

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

--no-install-recommends stops apt from dragging in documentation and optional extras, and the rm -rf /var/lib/apt/lists/* in the same RUN keeps the package index out of the committed layer. Combining both commands into one RUN matters: a separate cleanup layer cannot shrink the layer that already recorded the files.


The .dockerignore

Without a .dockerignore, the entire build context — including .git, local rasters, notebooks, and virtual environments — is sent to the daemon and can end up in COPY . . layers. Ship a tight one.

.git
.gitignore
**/__pycache__
**/*.pyc
.venv
venv
*.tif
*.tiff
data/
notebooks/
tests/
.pytest_cache
.mypy_cache
*.md
Dockerfile
.dockerignore

Excluding sample .tif files alone often removes gigabytes from the context. Your ONNX artifact belongs in the image, but training rasters and scratch data do not — mount those at runtime instead. If you are moving trained models into the image, see ONNX Export for Geospatial Model Inference for keeping the exported model itself compact.


Build and Size-Check Script

Automate the build and assert an upper bound on the image size so a regression fails CI instead of reaching a registry. The script below builds the image, prints the size, and exits non-zero if it exceeds a threshold.

#!/usr/bin/env bash
set -euo pipefail

IMAGE="geo-inference:slim"
MAX_MB=600

echo "Building ${IMAGE} ..."
docker build --tag "${IMAGE}" .

# Size in megabytes, integer.
SIZE_MB=$(docker image inspect "${IMAGE}" \
  --format '{{.Size}}' | awk '{ printf "%d", $1 / 1000000 }')

echo "Image size: ${SIZE_MB} MB (budget: ${MAX_MB} MB)"

if [ "${SIZE_MB}" -gt "${MAX_MB}" ]; then
  echo "FAIL: image exceeds ${MAX_MB} MB size budget." >&2
  exit 1
fi

echo "PASS: image is within the size budget."

Wire this into the pipeline so the size assertion runs on every change. A creeping image size is usually the first visible symptom of an accidental single-stage regression or a forgotten apt cleanup.


Walkthrough: From 3 GB to a Few Hundred MB

1. Start from python-slim, not a full OS base

python:3.12-slim is a Debian slim variant with the Python runtime and nothing else. It is roughly 120 MB versus 1 GB+ for a full python:3.12 or a hand-rolled ubuntu base. Because the wheels bundle GDAL, you do not need the extra system packages a fat base would provide.

2. Let the wheels carry GDAL

pip install rasterio pyproj in the builder stage downloads manylinux wheels that embed GDAL, PROJ, and GEOS. Confirm you are getting a wheel, not a source build, by watching for Downloading rasterio-...-manylinux...whl rather than a compile step. A compile step means pip fell back to source and you are missing a wheel for your platform or Python version.

3. Copy only the virtual environment

COPY --from=build /opt/venv /opt/venv transfers the fully resolved environment and nothing else. The compiler, pip cache, and apt metadata from the builder never touch the runtime image.

4. Purge caches in the layer that creates them

Every apt-get install must be paired with rm -rf /var/lib/apt/lists/* in the same RUN. PIP_NO_CACHE_DIR=1 and PYTHONDONTWRITEBYTECODE=1 stop pip and Python from writing caches you would otherwise have to clean.

5. Pin, set PROJ_NETWORK=OFF, and verify

Pin every dependency, set PROJ_NETWORK=OFF so transforms never reach for network grids, and run the verification below. The Serving an ONNX Land Cover Model with ONNXRuntime guide shows how the runtime session sits on top of exactly this image.


Verification

Two checks confirm the image is both small and functional: an image-size assertion and a smoke test that imports rasterio and reads a real tile inside the container.

# 1. Assert the image is under the size budget (see script above).
./build_and_check.sh

# 2. Smoke test: import rasterio, print GDAL version, read a sample tile.
docker run --rm \
  -v "$(pwd)/data:/data:ro" \
  geo-inference:slim \
  python - <<'PY'
import rasterio
from rasterio.windows import Window

print("rasterio", rasterio.__version__)
print("GDAL", rasterio.gdal_version())

# Read a 256x256 tile from the top-left corner of a sample raster.
with rasterio.open("/data/sample.tif") as src:
    print("CRS:", src.crs)
    tile = src.read(1, window=Window(0, 0, 256, 256))
    print("tile shape:", tile.shape, "dtype:", tile.dtype)
    print("valid pixels:", int((tile != src.nodata).sum()))
PY

A passing run prints the rasterio and GDAL versions, the raster CRS, and a (256, 256) tile shape — proving the bundled GDAL opens files and the read path works end to end. If rasterio.gdal_version() errors or the import fails, the wheel did not install cleanly and you likely fell back to a source build. Because PROJ_NETWORK=OFF is set, any CRS transform in your real inference code stays fully offline; validate those transforms against the guidance in CRS Alignment and Projection Handling.


FAQ

Should I install GDAL from apt or rely on the rasterio binary wheel?

For inference images, prefer the manylinux binary wheels of rasterio and pyproj. Each wheel bundles its own copy of GDAL, PROJ, and GEOS, so you avoid installing the full libgdal-dev toolchain and its build dependencies. This keeps the runtime layer to the shared libraries actually loaded at import time and produces a smaller, more reproducible image than compiling against a system GDAL. Reach for system libgdal only when you need a driver or version the wheels do not provide.

Why is my image still 3 GB after switching to python-slim?

The base image is rarely the problem. The bulk usually comes from build toolchains (gcc, libgdal-dev, pip’s build cache), apt package lists left in the final layer, and dev headers pulled in by recommended packages. Move compilation into a builder stage, copy only the installed environment into a clean runtime stage, add --no-install-recommends, and remove /var/lib/apt/lists in the same RUN layer that installed the packages.

What does PROJ_NETWORK=OFF do and why set it in the image?

PROJ can fetch high-accuracy datum grids over the network at runtime. In a sealed inference container that behaviour causes unpredictable latency, silent failures on air-gapped hosts, and non-reproducible transforms. Setting PROJ_NETWORK=OFF forces PROJ to use only the grids shipped inside the wheel or image, keeping every coordinate transform deterministic and offline.


Part of: Containerizing Geospatial Inference PipelinesGeospatial MLOps and Model Deployment