← Back to Containerizing PostGIS & FastAPI
Compile the geospatial Python stack once in a throwaway builder stage, then ship a slim final image that carries only the virtualenv and the runtime .so libraries — never the compiler.
Context & when to use
A single-stage image that can build rasterio and the GDAL bindings must contain build-essential, libgdal-dev, libgeos-dev, and libproj-dev — hundreds of megabytes of compilers and headers that do nothing at runtime and widen your attack surface. A multi-stage build separates the two concerns: a builder stage has the full toolchain and produces a self-contained virtualenv, and a runtime stage starts fresh from python:3.12-slim, installs only the runtime shared libraries, and copies the finished venv across. The final image is smaller, faster to pull, and contains no compiler for an attacker to leverage.
Reach for this pattern once your image is heading to production or to any registry pull path that matters — CI, autoscaling, or edge nodes. For purely local development, the single-stage Dockerfile in the parent guide, Containerizing PostGIS & FastAPI, is simpler and rebuilds just as fast thanks to layer caching. The one precondition that makes multi-stage safe for geospatial code is library-version alignment: the libgdal-dev the bindings compile against in the builder must have the same major version as the libgdal32 runtime package in the final stage, or the copied extension will fail to load. Because both come from the same Debian bookworm source package here, they align automatically.
Two-stage build diagram
Runnable implementation
One Dockerfile with two FROM statements. Every non-obvious line is annotated inline.
# syntax=docker/dockerfile:1.7
# ============================================================
# STAGE 1 — builder: has the compiler + dev headers.
# Its only job is to produce a fully populated /opt/venv.
# ============================================================
FROM python:3.12-slim AS builder
# Dev headers + toolchain. Needed to compile any dependency that
# does NOT ship a manylinux wheel (or when you build wheels yourself).
# --no-install-recommends stops apt pulling in docs/suggested extras.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libgdal-dev \
libgeos-dev \
libproj-dev \
&& rm -rf /var/lib/apt/lists/*
# A venv is the cleanest unit to hand across stages: one directory,
# no reliance on the runtime image's site-packages layout.
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# GDAL bindings must match the system libgdal version exactly. Pin the
# Python package to the libgdal-dev version apt just installed so the
# ABI matches on both sides of the stage boundary.
ARG GDAL_VERSION=3.6.2
WORKDIR /build
COPY requirements.txt .
# BuildKit cache mount: pip's wheel cache survives across builds even
# when this layer is invalidated, so one changed dep is not a full
# re-download of the geospatial stack.
RUN \
pip install --no-cache-dir \
"GDAL[numpy]==${GDAL_VERSION}" \
&& pip install --no-cache-dir -r requirements.txt
# ============================================================
# STAGE 2 — runtime: fresh slim base, NO compiler, NO headers.
# Ships only runtime .so libraries + the copied venv.
# ============================================================
FROM python:3.12-slim AS runtime
# Runtime shared objects only — the "-dev" packages are deliberately
# absent. These are what rasterio and osgeo.gdal dlopen at import time.
# curl is here for the HEALTHCHECK; drop it if you probe differently.
RUN apt-get update && apt-get install -y --no-install-recommends \
libgdal32 \
libgeos-c1v5 \
libproj25 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root runtime user. Created before the COPY so ownership is set once.
RUN groupadd --system app && useradd --system --gid app --home /app app
# The whole point of the split: bring the built venv across, leaving
# build-essential / *-dev behind in the discarded builder layer.
COPY /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY . .
# Fail the BUILD (not a request) if any compiled extension can't load
# because a runtime .so is missing from this stage.
RUN python -c "import shapely, pyproj, rasterio; from osgeo import gdal; \
print('ok', gdal.__version__)"
USER app
EXPOSE 8000
HEALTHCHECK \
CMD curl -fsS http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Key parameters & options
| Parameter / flag | Purpose | Recommended value |
|---|---|---|
AS builder / AS runtime | Names the stages so COPY --from=builder can reference the first | Always name stages explicitly |
python -m venv /opt/venv | Isolates all deps in one copyable directory | /opt/venv, then prepend to PATH |
--mount=type=cache,target=/root/.cache/pip | Persists pip’s wheel cache across builds | Always, on the builder pip step |
--no-install-recommends | Stops apt pulling in suggested extras | Always, both stages |
ARG GDAL_VERSION | Pins the Python GDAL binding to the system libgdal | Match libgdal-dev version exactly |
libgdal-dev (builder) vs libgdal32 (runtime) | Compile-time headers vs runtime .so | Same major version from the same distro |
COPY --from=builder /opt/venv /opt/venv | Moves the built stack into the slim image | The only artifact crossing the boundary |
COPY --chown=app:app | Sets file ownership at copy time | Avoids a separate chown layer |
Gotchas & failure modes
Missing runtime
.soin the final stage. The build succeeds, the image is small, and thenimport rasterioraisesImportError: libgdal.so.32: cannot open shared object file. The builder hadlibgdal-devbut the runtime stage forgotlibgdal32. TheRUN python -c "import ..."smoke test in the runtime stage turns this into a build failure instead of a 2am incident — never omit it.GDAL version mismatch between build and runtime. If the builder compiles the bindings against
libgdal-dev3.6 but the runtime stage installs alibgdal32from a different distro release (say 3.8), the import fails withsymbol lookup erroror a segfault on the first GDAL call. Keep both stages on the same base distribution (python:3.12-slim, both bookworm) soaptresolves the same GDAL major.Layer cache invalidation from copying source too early. Placing
COPY . .before thepip installstep means any code edit busts the dependency layer and recompiles the entire geospatial stack — minutes per build. Copyrequirements.txtfirst, install, then copy the source. The parent guide, Containerizing PostGIS & FastAPI, applies the same ordering to the single-stage image.Copying
site-packagesinstead of the venv. Copying/usr/local/lib/python3.12/site-packagesacross stages misses console-script shims andpyvenv.cfg, so entry points likeuvicornare absent. Copy the whole/opt/venvand put it onPATH.Root-owned venv breaking a non-root user. If you
COPY --from=builder /opt/venvand then switch toUSER app, the app can still read the venv (world-readable by default), but a build step that writes into it as root followed by an app-time write will fail withPermissionError. Keep the venv read-only at runtime; never write into/opt/venvafter the build.
Verification
Prove the image is both smaller and complete. Size first, then ldd to confirm every compiled extension resolves its shared libraries inside the final image:
# Build both variants and compare on-disk size.
docker build -t geo-api:multistage .
docker images geo-api --format '{{.Tag}}\t{{.Size}}'
# multistage ~420MB (vs ~640MB single-stage with build tooling)
# Resolve the .so dependencies of the compiled rasterio extension.
# Every line must show a real path — no "not found".
docker run --rm geo-api:multistage sh -c '
SO=$(python -c "import rasterio._base, os; print(rasterio._base.__file__)")
ldd "$SO"'
# ... libgdal.so.32 => /usr/lib/x86_64-linux-gnu/libgdal.so.32 (0x...)
# ... libproj.so.25 => /usr/lib/x86_64-linux-gnu/libproj.so.25 (0x...)
# Assert no unresolved symbols across the whole venv (exits non-zero on any).
docker run --rm geo-api:multistage sh -c '
find /opt/venv -name "*.so" -exec ldd {} \; 2>/dev/null | grep "not found"' \
&& echo "MISSING LIBS" || echo "all shared libs resolved"A not found in any ldd line is the multi-stage failure signature: a runtime .so the builder had but the runtime stage lacks. Add the missing libXXX package to the runtime stage and rebuild. Once this is clean, pin the image so the resolved versions never drift — see Pinning PostGIS Versions in Production Images.
Related
- Containerizing PostGIS & FastAPI — base image choices, the compose stack, and PostGIS-aware healthchecks this build slots into
- Pinning PostGIS Versions in Production Images — lock the resolved library versions by tag and digest so builds stay reproducible
- Deploying & Operating Geospatial APIs — the delivery guide covering CI, migrations, and edge tile distribution
← Back to Containerizing PostGIS & FastAPI