Building a Slim GDAL Docker Image

The shortest route to a small geospatial image is a two-stage build that installs from wheels and copies only the resulting packages into a clean runtime:

# syntax=docker/dockerfile:1
FROM python:3.11-slim-bookworm AS builder
COPY requirements.lock /tmp/requirements.lock
RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.lock

FROM python:3.11-slim-bookworm AS runtime
COPY --from=builder /install /usr/local
COPY src/ /app/src/
ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR PROJ_NETWORK=OFF PYTHONUNBUFFERED=1
ENTRYPOINT ["python", "-m", "src.main"]

This is the practical build behind the sizing argument in Containerizing Geospatial Python Environments.


Why This Arises in Remote Sensing Workflows

The default geospatial Dockerfile is large because every reasonable-looking decision adds a few hundred megabytes. Starting from a full distribution image rather than a slim one adds around 600 MB. Installing build-essential to compile a package that actually ships a wheel adds another 300. Leaving pip’s cache in the layer adds the size of every wheel a second time. None of them is obviously wrong, and together they turn a 400 MB image into 1.8 GB.

The cost lands where the image is pulled most: array jobs. Every container in a ten-thousand-task run pulls the image before doing any work, so image size multiplies directly into billed compute time, as the timing comparison in the parent topic shows. It also lands on developers, since every rebuild pushes and pulls those layers.

The fix is structural rather than clever. Separate the environment that builds the dependencies from the one that runs them, and install from wheels so the build environment barely needs to exist.

Where the gigabytes come from A naive image is dominated by the full base distribution, a compiler toolchain and pip's cache, with the geospatial libraries themselves a small fraction. A multi-stage build on a slim base with wheel-only installs removes the first three entirely, leaving the libraries and the code. Same pipeline, two builds naive single-stage full base image 620 MB toolchain 310 MB pip cache libs 1.8 GB total — 22% of it is what the job actually uses multi-stage, wheels only slim 150 rasterio + deps 250 MB code 420 MB total — and the dependency layer is cached across code changes The libraries are the same in both. Everything removed was scaffolding. A slim image is not a compromise — it contains exactly what runs.

Environment & Setup

Tool Version Why
Docker / BuildKit ≥24 Multi-stage builds, cache mounts, --platform
pip ≥23.1 --prefix installs and hash checking
pip-tools or uv current Producing a hashed lock file
dive (optional) ≥0.11 Inspecting layer sizes
pip install pip-tools
pip-compile --generate-hashes -o requirements.lock requirements.in

Complete Working Example

A full Dockerfile with the layer ordering, the runtime configuration and the build-time smoke test in place.

Size contribution of each build decision A full base image, a compiler toolchain and pip caches account for most of a naive geospatial image. Each has a specific remedy, and together they take a 1.8 gigabyte image to around 420 megabytes without removing anything the job uses. Where each saving comes from decision saves how slim base instead of full ~470 MB python:3.11-slim-bookworm multi-stage build ~310 MB toolchain stays in the builder --no-cache-dir ~180 MB pip cache never enters a layer PYTHONDONTWRITEBYTECODE ~40 MB no .pyc in the image apt lists removed ~30 MB rm -rf /var/lib/apt/lists/* None of these remove a capability — they remove scaffolding the runtime never uses.
# syntax=docker/dockerfile:1
ARG PYTHON_VERSION=3.11.9

# ── builder: resolves and installs, then is discarded ─────────────────────────
FROM python:${PYTHON_VERSION}-slim-bookworm AS builder

ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1

# Only needed if some dependency lacks a wheel; drop it entirely when they all do
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.lock /tmp/requirements.lock
RUN pip install --require-hashes --prefix=/install -r /tmp/requirements.lock

# ── runtime: nothing but the interpreter, the packages and the code ───────────
FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime

# Certificates are needed for HTTPS reads of remote COGs
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /install /usr/local

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
    GDAL_INGESTED_BYTES_AT_OPEN=65536 \
    GDAL_HTTP_MULTIRANGE=YES \
    GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES \
    GDAL_CACHEMAX=512 \
    VSI_CACHE=TRUE \
    PROJ_NETWORK=OFF

WORKDIR /app
COPY src/ /app/src/

# Record and verify the stack in one step; a bad resolve fails the build
RUN python - <<'PY'
import json, sys, rasterio, pyproj
info = {"python": sys.version.split()[0],
        "rasterio": rasterio.__version__,
        "gdal": rasterio.__gdal_version__,
        "proj": pyproj.proj_version_str}
json.dump(info, open("/app/versions.json", "w"))
print(info)
assert rasterio.__gdal_version__ >= "3.4", "GDAL too old for the COG driver"
PY

USER 1000:1000
ENTRYPOINT ["python", "-m", "src.main"]

Three choices carry the size reduction. --prefix=/install gives the builder a self-contained tree that can be copied wholesale, so nothing from the build environment follows it. The runtime stage installs only ca-certificates, because HTTPS reads fail without them and everything else is unnecessary. And PYTHONDONTWRITEBYTECODE keeps .pyc files out of the layer, which matters more than it sounds for a large dependency tree.


Variant Patterns

1. When a system GDAL is genuinely needed

If the pipeline shells out to gdalwarp, or needs a driver the wheels omit, start from a GDAL base and install rasterio without its bundled libraries.

FROM ghcr.io/osgeo/gdal:ubuntu-small-3.8.4 AS runtime

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

# --no-binary rasterio builds against the system GDAL rather than shipping a second copy
RUN pip install --no-cache-dir --no-binary rasterio "rasterio==1.3.9"

The ubuntu-small variant is the one to reach for: the full image carries drivers most pipelines never touch. Shipping both a system GDAL and a wheel-bundled GDAL is the worst outcome — two copies, and whichever loads first wins.

2. Cache mounts to speed up rebuilds

Cache mounts move download time out of the rebuild Without a cache mount, changing one pinned version re-downloads every wheel. With a BuildKit cache mount, pip reuses previously downloaded wheels from a persistent cache that never enters the image, so only the changed package is fetched. Rebuild after bumping one pin no cache mount download every wheel again — 96 s install --mount=type=cache 1 wheel install — 14 s, and the cache never enters the image RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.lock The cache lives in the builder, not in a layer — image size is unchanged. Combine with layer ordering: the cache speeds the rebuild, the ordering avoids it entirely.

3. Trimming what the copy brings across

COPY --from=builder /install /usr/local brings tests, headers and documentation that some packages ship. On a large dependency tree, removing them is worth tens of megabytes.

RUN find /usr/local/lib/python3.11/site-packages \
        \( -name "tests" -o -name "test" -o -name "*.dist-info" -prune -o -name "*.pyx" \) \
        -exec rm -rf {} + 2>/dev/null || true

Be conservative here: deleting *.dist-info breaks version introspection, and deleting anything under pyproj/proj_dir breaks every coordinate transformation. When in doubt, measure first with dive and only remove what is both large and provably unused.


Measuring the Result

Two commands answer whether the build did what you intended.

# Total size and layer-by-layer breakdown
docker image ls raster-pipeline:latest
docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" raster-pipeline:latest | head -20

Look for three things. No layer should contain a package manager cache — if one does, --no-cache-dir or an rm -rf /var/lib/apt/lists/* is missing. The dependency layer should be the largest and should not change when only code changes. And no layer should contain a compiler, which is the signature of a builder stage that was not actually separated.

A useful regression test is to record the image size in the build pipeline and fail on a sudden jump. Images grow gradually as dependencies are added, and a 300 MB step usually means a base image changed or a toolchain crept back in.


Common Errors

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

The runtime stage is missing a shared library the copied packages link against. Either install the specific package that provides it, or use the same base image for both stages so the library set matches.

The image runs on the laptop and not on the fleet

An architecture mismatch. Build with --platform linux/amd64 when the fleet is amd64, and check docker inspect for the architecture before pushing.

CERTIFICATE_VERIFY_FAILED reading a remote COG

ca-certificates was not installed in the slim runtime stage. It is one of the few apt packages the runtime genuinely needs.


Frequently Asked Questions

Q: Why is my geospatial image so large? Usually three things: a full distribution base rather than a slim one, a compiler toolchain installed to build a package that has a wheel, and pip’s cache left in the image. Together they account for well over a gigabyte.

Q: Is Alpine a good base for rasterio? Usually not. Alpine uses musl rather than glibc, so manylinux wheels do not install and everything must be compiled from source — a slower build, a larger builder stage and a stack that differs from the one everyone else tests against.

Q: Can I delete the PROJ data to save space? No. proj.db is what resolves coordinate operations, and removing it breaks every reprojection with an error that looks unrelated. It is a few tens of megabytes and it is not optional.