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 /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.
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.
# 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 /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
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.
Related
- Containerizing Geospatial Python Environments — the parent topic, including what belongs in an image.
- Pinning GDAL and PROJ Versions Reproducibly — the lock file this build consumes.
- Caching PROJ Data and GDAL Config in Containers — the environment variables set in the runtime stage.
- Processing COGs on AWS Batch with Docker — where the pull time this saves is actually paid.