Skip to content

Building Minimal Docker Images with Alpine and GDAL

Use a two-stage alpine:3.19 build: compile GDAL and Python bindings in stage 1, then COPY only the runtime .so files and site-packages into a clean stage 2. Strip debug symbols with strip --strip-unneeded before the copy. The result fits comfortably under 250 MB uncompressed, which is safe for AWS Lambda (10 GB image limit), GCP Cloud Run (32 GB), and Azure Container Apps — while keeping cold-start latency well below 1.5 s.


Multi-stage Alpine + GDAL Docker build Builder stage (alpine:3.19) installs build-base, gdal, py3-gdal, strips libgdal.so, then COPY transfers libgdal.so, osgeo site-packages, and GDAL/PROJ data into a clean Runtime stage. Build tools and headers are discarded. Stage 1 — builder (alpine:3.19) apk add build-base cmake python3-dev apk add gdal py3-gdal proj-dev geos-dev apk add libtiff-dev libjpeg-turbo-dev … strip --strip-unneeded /usr/lib/libgdal.so.* discarded: build-base, *-dev headers, static libs, apk cache, debug symbols COPY --from=builder Stage 2 — runtime (alpine:3.19) apk add python3 proj geos sqlite libtiff libjpeg-turbo libpng curl /usr/lib/libgdal.so.* /usr/lib/python3.x/site-packages/osgeo /usr/share/gdal /usr/share/proj ENV GDAL_DATA=/usr/share/gdal ENV PROJ_LIB=/usr/share/proj ENV PYTHONPATH=/usr/lib/python3.x/…

Context

Docker Container Optimization for GIS sets the container size budget and explains how deployment package size directly drives cold-start latency. Within that constraint, Alpine-based images are the most aggressive size-reduction option available — a naive python:3.11 base with GDAL installed runs past 1 GB, while the Alpine multi-stage approach described here typically lands around 180–220 MB.

The failure mode this technique prevents is exceeding platform deployment limits or triggering cold starts long enough to breach function timeout budgets. Cold Start Mapping for Python GDAL documents that libgdal.so alone accounts for 40–60 % of the shared-library resolution phase — so cutting image size and stripping debug symbols directly reduces that phase duration. On AWS Lambda, a 10 GB /tmp ephemeral store (Ephemeral Storage Limits in AWS Lambda) is not consumed by the image itself, but the container pull still blocks the first invocation until the image layer is cached in the execution environment.

Prerequisites

  • Docker 24+ (multi-stage COPY --from is stable since Docker 17.05, but BuildKit is enabled by default from 24+)
  • alpine:3.19 or alpine:3.20 pinned in both stages — do not float latest
  • GDAL 3.7+ available in Alpine’s community repository (already enabled in 3.19)
  • PROJ_LIB and GDAL_DATA writable by the runtime user; no IAM permissions required for local builds, but see IAM Security Boundaries for Cloud GIS when pulling from a private registry
  • Python 3.11+ (py3-gdal on Alpine 3.19 ships against Python 3.11)
  • No manylinux wheels — Alpine uses musl libc; see the Gotchas section below

Implementation

The Dockerfile below is production-ready. Read the inline comments — several of them call out decisions that are easy to get wrong.

dockerfile
# ─── Stage 1: builder ─────────────────────────────────────────────────────────
FROM alpine:3.19 AS builder

# Build toolchain + all GDAL and Python development headers.
# apk resolves PROJ, GEOS, SQLite, TIFF, JPEG, PNG, WebP, and CURL transitively.
RUN apk add --no-cache \
    build-base cmake python3-dev py3-pip \
    proj-dev sqlite-dev libtiff-dev libjpeg-turbo-dev \
    libpng-dev libwebp-dev curl-dev zlib-dev \
    geos-dev expat-dev \
    gdal gdal-dev py3-gdal

# Strip debug symbols from the shared library *before* COPY.
# This cuts libgdal.so size by 30–40 % with no runtime impact.
RUN strip --strip-unneeded /usr/lib/libgdal.so.* 2>/dev/null || true

# ─── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM alpine:3.19

# Only runtime packages — no compilers, no *-dev headers.
# These packages resolve the same .so chain that libgdal was compiled against.
RUN apk add --no-cache \
    python3 py3-pip \
    libtiff libjpeg-turbo libpng libwebp curl \
    sqlite-libs proj geos expat

# Detect the Python minor version in one RUN to avoid hard-coding 3.11.
# Alpine 3.19 ships 3.11; Alpine 3.20 ships 3.12. Parameterise to be safe.
ARG PY_VER=3.11

# Copy stripped GDAL shared library.
COPY --from=builder /usr/lib/libgdal.so.* /usr/lib/

# Copy the Python GDAL bindings from builder's site-packages.
COPY --from=builder /usr/lib/python${PY_VER}/site-packages/osgeo \
                    /usr/lib/python${PY_VER}/site-packages/osgeo
COPY --from=builder /usr/lib/python${PY_VER}/site-packages/gdal.py \
                    /usr/lib/python${PY_VER}/site-packages/gdal.py

# Copy GDAL and PROJ data directories — required for CRS lookups and drivers.
# Without /usr/share/proj, ImportFromEPSG() raises a proj.db not found error.
COPY --from=builder /usr/share/gdal /usr/share/gdal
COPY --from=builder /usr/share/proj /usr/share/proj

# These env vars are mandatory — never leave them to be set externally.
# GDAL and PROJ cannot locate their data files without them in a container.
ENV GDAL_DATA=/usr/share/gdal \
    PROJ_LIB=/usr/share/proj \
    PYTHONPATH=/usr/lib/python${PY_VER}/site-packages \
    PYTHONUNBUFFERED=1

# GDAL serverless tuning: disable filesystem scans and HTTP caching
# that waste cycles in stateless, ephemeral execution environments.
ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
    CPL_VSIL_CURL_CACHE_SIZE=0 \
    VSI_CACHE=FALSE

WORKDIR /app
COPY requirements.txt .
# Install only pure-Python or musl-compatible wheels from requirements.txt.
# Do NOT include rasterio or fiona here via PyPI — use apk or compile from source.
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
CMD ["python", "handler.py"]

Why strip must run inside stage 1, not stage 2

Once files are copied into the runtime stage the builder context is gone. Run strip in stage 1 so the slimmed .so is what gets transferred. On a typical GDAL 3.7 build, libgdal.so drops from ~85 MB to ~52 MB after stripping.

Verification

Run each check in order. A failure in step 2 usually points to a missing COPY or a wrong PY_VER argument; a failure in step 3 means /usr/share/proj was not copied correctly.

bash
# 1. Size gate — must be under 250 MB uncompressed
docker images --format "{{.Repository}}:{{.Tag}}  {{.Size}}" | grep your-image-name

# 2. GDAL import test — confirms libgdal.so and site-packages are wired correctly
docker run --rm your-image:latest python3 -c "
from osgeo import gdal
print('GDAL version:', gdal.__version__)
print('GDAL_DATA:', gdal.GetConfigOption('GDAL_DATA'))
"
# Expected output:
# GDAL version: 3.7.x
# GDAL_DATA: /usr/share/gdal

# 3. CRS round-trip — confirms PROJ data and proj.db are present
docker run --rm your-image:latest python3 -c "
from osgeo import osr
srs = osr.SpatialReference()
srs.ImportFromEPSG(4326)
wkt = srs.ExportToWkt()
assert 'WGS 84' in wkt, 'CRS lookup failed — check PROJ_LIB'
print('CRS OK:', wkt[:40], '...')
"

# 4. Cold-start benchmark — target under 1.5 s
time docker run --rm your-image:latest python3 handler.py

Gotchas and Edge Cases

  • manylinux wheels silently link against glibc symbols. pip install rasterio inside an Alpine container downloads a manylinux_2_17_x86_64 wheel that calls libm.so.6 — a symbol that does not exist in musl. The error surfaces as Error loading shared library libm.so.6: No such file or directory at import time, not at install time. Fix: use apk add py3-rasterio py3-fiona to install Alpine-native builds, or add --no-binary rasterio to force a source compile inside the builder stage.

  • Python version mismatch between stages. If you apk add python3 in the runtime stage but Alpine upgrades the minor version between the builder pull and the runtime pull (e.g., builder got 3.11, runtime got 3.12 from a later image cache), the COPY path /usr/lib/python3.11/site-packages/osgeo will not exist in the runtime layer. Pin apk add python3=3.11.* explicitly or pass ARG PY_VER to both stages.

  • PROJ_LIB set to the wrong path kills all CRS operations. PROJ 9 looks for proj.db inside $PROJ_LIB. If the variable is unset or points at an empty directory, every ImportFromEPSG() call raises PROJ: proj_create_from_database: Cannot find proj.db. The ENV line in the Dockerfile is mandatory — do not rely on PROJ’s compiled-in default paths, which differ between Alpine and the base image used in local development.

  • docker layer caching on COPY . . invalidates the size optimizations. Place COPY . . last, after all apk, pip, and COPY --from=builder instructions. A source-code change then only invalidates the final layer, keeping the heavy GDAL layers cached across builds.

Frequently Asked Questions

Why do manylinux wheels fail inside Alpine containers?

Alpine uses musl libc instead of glibc. Pre-built manylinux wheels link against glibc symbols (libm.so.6, libc.so.6) that do not exist in musl. You must either install Alpine-native packages via apk (py3-gdal, py3-rasterio) or compile from source inside the Alpine build stage.

What is the safe uncompressed size target for serverless container images?

Target under 250 MB uncompressed. AWS Lambda container images support up to 10 GB, GCP Cloud Functions 2nd gen up to 32 GB, and Azure Container Apps up to several GB — but smaller images reduce cold-start latency and registry pull time regardless of hard limits.

Which Alpine version should I pin for GDAL 3.x?

Pin alpine:3.19 or alpine:3.20. Both carry GDAL 3.7+ in the community repository and use PROJ 9.x, satisfying the GDAL 3+ requirement for PROJ 6+. Avoid floating the latest tag — a minor Alpine bump can pull a newer GDAL that changes driver registration order or drops deprecated options.


Back to Packaging & Dependency Management for Serverless GIS