Multi-Stage Docker Builds for PostGIS & FastAPI

Compile shapely, pyproj, and the GDAL bindings in a builder stage, then copy only the virtualenv and runtime shared libraries into a slim non-root final image — with the ldd checks that prove no .so is missing.

← 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

Multi-stage build: builder to slim runtimeThe builder stage contains build-essential, libgdal-dev, libgeos-dev, and libproj-dev, and produces a virtualenv at /opt/venv holding shapely, pyproj, rasterio, and the GDAL bindings. Only /opt/venv is copied into the runtime stage, which starts from python:3.12-slim and installs only libgdal32, libgeos-c1v5, and libproj25. The compiler and dev headers are discarded.STAGE 1 — builderbuild-essential · libgdal-devlibgeos-dev · libproj-devpip install → /opt/venvshapely · pyproj · rasterio · GDALcompiled against dev headerscompiler + headers discarded ✕stage never shippedCOPY/opt/venvSTAGE 2 — runtime (shipped)python:3.12-slimlibgdal32 · libgeos-c1v5libproj25 (runtime only)/opt/venv (copied in)USER app · non-rootno compiler · ~420 MBuvicorn app.main:app

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 --mount=type=cache,target=/root/.cache/pip \
    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 --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONUNBUFFERED=1

WORKDIR /app
COPY --chown=app:app . .

# 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 --interval=15s --timeout=5s --start-period=20s --retries=3 \
    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 / flagPurposeRecommended value
AS builder / AS runtimeNames the stages so COPY --from=builder can reference the firstAlways name stages explicitly
python -m venv /opt/venvIsolates all deps in one copyable directory/opt/venv, then prepend to PATH
--mount=type=cache,target=/root/.cache/pipPersists pip’s wheel cache across buildsAlways, on the builder pip step
--no-install-recommendsStops apt pulling in suggested extrasAlways, both stages
ARG GDAL_VERSIONPins the Python GDAL binding to the system libgdalMatch libgdal-dev version exactly
libgdal-dev (builder) vs libgdal32 (runtime)Compile-time headers vs runtime .soSame major version from the same distro
COPY --from=builder /opt/venv /opt/venvMoves the built stack into the slim imageThe only artifact crossing the boundary
COPY --chown=app:appSets file ownership at copy timeAvoids a separate chown layer

Gotchas & failure modes

  • Missing runtime .so in the final stage. The build succeeds, the image is small, and then import rasterio raises ImportError: libgdal.so.32: cannot open shared object file. The builder had libgdal-dev but the runtime stage forgot libgdal32. The RUN 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-dev 3.6 but the runtime stage installs a libgdal32 from a different distro release (say 3.8), the import fails with symbol lookup error or a segfault on the first GDAL call. Keep both stages on the same base distribution (python:3.12-slim, both bookworm) so apt resolves the same GDAL major.

  • Layer cache invalidation from copying source too early. Placing COPY . . before the pip install step means any code edit busts the dependency layer and recompiles the entire geospatial stack — minutes per build. Copy requirements.txt first, install, then copy the source. The parent guide, Containerizing PostGIS & FastAPI, applies the same ordering to the single-stage image.

  • Copying site-packages instead of the venv. Copying /usr/local/lib/python3.12/site-packages across stages misses console-script shims and pyvenv.cfg, so entry points like uvicorn are absent. Copy the whole /opt/venv and put it on PATH.

  • Root-owned venv breaking a non-root user. If you COPY --from=builder /opt/venv and then switch to USER 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 with PermissionError. Keep the venv read-only at runtime; never write into /opt/venv after 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.


← Back to Containerizing PostGIS & FastAPI