Skip to content

Multi-Stage Dockerfile for GDAL on Cloud Run

Compile GDAL, PROJ, and GEOS in a full-toolchain builder stage, strip the binaries, then COPY --from=builder only the shared objects, proj.db/GDAL data, and the Python venv into a python:3.11-slim runtime stage — discarding every compiler, header, and source tree. For a rasterio + pyproj Cloud Run service this takes the image from roughly 1.4 GB single-stage down to about 320 MB, and with GDAL_DATA, PROJ_LIB, and LD_LIBRARY_PATH set explicitly the container binds Cloud Run’s injected PORT and serves without touching a package manager at runtime.


Multi-stage GDAL Dockerfile for Cloud Run A builder stage with the full toolchain compiles GDAL, PROJ, GEOS and the venv at about 1.4 GB. A COPY --from=builder step transfers only the stripped shared objects, proj and gdal data, and the venv into a python-slim runtime stage of about 320 MB that serves on the Cloud Run PORT. builder stage — ~1.4 GB gcc, cmake, make, -dev headers compile GEOS → PROJ → GDAL pip install into /opt/venv strip --strip-unneeded *.so runtime artifacts .so + proj.db + venv COPY --from runtime stage — ~320 MB python:3.11-slim base apt: libcurl, openssl only copied .so + data + venv GDAL_DATA / PROJ_LIB / LD_LIBRARY_PATH bind $PORT — no compilers Cloud Run Only stripped runtime artifacts cross the stage boundary — the toolchain is discarded

Context

Unlike AWS Lambda, GCP Cloud Functions 2nd gen and Cloud Run do not support Lambda-style layers — the compiled geospatial stack must live inside the container image itself. That makes image size the primary optimization target, and it is the central concern of Docker container optimization for GIS. A naive single-stage Dockerfile that runs apt-get install gdal-bin libgdal-dev and pip install rasterio bakes the entire build toolchain, every -dev header package, and the source archives into the shipped image — commonly 1.2–1.6 GB.

Cloud Run does not enforce a hard image-size ceiling the way Lambda caps layers at 250 MB unzipped, but it pulls the image on every cold start of a new instance. A 1.4 GB image adds several seconds of registry pull to that cold start; a 320 MB image pulls in a fraction of the time and keeps you well inside Cloud Run’s 32 GB memory and 60-minute request envelope. The technique is the same multi-stage separation used when building minimal Docker images with Alpine and GDAL, but targeted at a Debian-slim runtime, which avoids the musl compatibility friction that Alpine introduces for some geospatial wheels.

The compilation itself follows the native library compilation for serverless rules — the difference here is that the output stays inside a container rather than being packaged as a layer, and a COPY --from=builder boundary enforces that only runtime artifacts survive.

Prerequisites

  • Docker with BuildKit enabled (DOCKER_BUILDKIT=1) for efficient multi-stage builds and layer caching
  • Cloud Run 2nd gen target (32 GB memory, 3,000 concurrency, 60 minute timeout ceiling) with Artifact Registry as the image host
  • Pinned versions — ideally sourced from the same file used to pin GDAL and PROJ across build and runtime
  • Application entrypoint: an ASGI app (FastAPI/Uvicorn shown) that must bind 0.0.0.0:$PORT — Cloud Run injects PORT (default 8080)
  • Runtime environment variables copied into the final stage:
    code
    GDAL_DATA=/opt/gdal/share/gdal
    PROJ_LIB=/opt/gdal/share/proj
    LD_LIBRARY_PATH=/opt/gdal/lib
    
  • IAM: the deploy identity needs run.services.create/update and artifactregistry.repositories.uploadArtifacts; the runtime service account needs only the storage roles your handler actually uses

Implementation

The Dockerfile below is complete. The builder stage owns the toolchain and compiles the stack; the runtime stage starts clean from python:3.11-slim and receives only what the running service needs.

dockerfile
# syntax=docker/dockerfile:1.7

########################  BUILDER STAGE  ########################
# Full toolchain — everything here is discarded from the final image.
FROM python:3.11-bookworm AS builder

ARG GDAL_VERSION=3.9.0
ARG PROJ_VERSION=9.4.0
ARG GEOS_VERSION=3.12.1
ENV PREFIX=/opt/gdal

# Build-only dependencies: compilers, cmake, and -dev headers.
RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential cmake make pkg-config git \
      libsqlite3-dev libtiff-dev libcurl4-openssl-dev \
      zlib1g-dev libpng-dev libexpat1-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /src

# Compile lowest-level first: PROJ, then GEOS, then GDAL against both.
RUN curl -sSL "https://download.osgeo.org/proj/proj-${PROJ_VERSION}.tar.gz" | tar xz && \
    cmake -S "proj-${PROJ_VERSION}" -B build-proj \
      -DCMAKE_INSTALL_PREFIX="${PREFIX}" -DCMAKE_BUILD_TYPE=Release \
      -DBUILD_TESTING=OFF -DBUILD_SHARED_LIBS=ON && \
    cmake --build build-proj --parallel "$(nproc)" && cmake --install build-proj

RUN curl -sSL "https://download.osgeo.org/geos/geos-${GEOS_VERSION}.tar.bz2" | tar xj && \
    cmake -S "geos-${GEOS_VERSION}" -B build-geos \
      -DCMAKE_INSTALL_PREFIX="${PREFIX}" -DCMAKE_BUILD_TYPE=Release \
      -DBUILD_TESTING=OFF && \
    cmake --build build-geos --parallel "$(nproc)" && cmake --install build-geos

RUN curl -sSL "https://github.com/OSGeo/gdal/releases/download/v${GDAL_VERSION}/gdal-${GDAL_VERSION}.tar.gz" | tar xz && \
    cmake -S "gdal-${GDAL_VERSION}" -B build-gdal \
      -DCMAKE_INSTALL_PREFIX="${PREFIX}" -DCMAKE_BUILD_TYPE=Release \
      -DPROJ_ROOT="${PREFIX}" -DGEOS_ROOT="${PREFIX}" -DBUILD_TESTING=OFF && \
    cmake --build build-gdal --parallel "$(nproc)" && cmake --install build-gdal

# Strip debug symbols — this is where most of the size drop happens.
RUN find "${PREFIX}/lib" -name "*.so*" -exec strip --strip-unneeded {} + 2>/dev/null || true

# Build the Python venv against the freshly compiled GDAL.
ENV PATH="${PREFIX}/bin:${PATH}" \
    LD_LIBRARY_PATH="${PREFIX}/lib" \
    GDAL_CONFIG="${PREFIX}/bin/gdal-config"
RUN python -m venv /opt/venv && \
    /opt/venv/bin/pip install --no-cache-dir --upgrade pip && \
    /opt/venv/bin/pip install --no-cache-dir \
      "rasterio==1.4.3" "pyproj==3.7.0" "shapely==2.0.6" \
      "fastapi==0.115.0" "uvicorn[standard]==0.30.6"

########################  RUNTIME STAGE  ########################
# Slim base — no compilers, no headers, no source trees.
FROM python:3.11-slim-bookworm AS runtime

# Only the shared runtime libs GDAL dynamically links (no -dev variants).
RUN apt-get update && apt-get install -y --no-install-recommends \
      libcurl4 libexpat1 libtiff6 && \
    rm -rf /var/lib/apt/lists/*

# Copy ONLY runtime artifacts across the stage boundary.
COPY --from=builder /opt/gdal/lib        /opt/gdal/lib
COPY --from=builder /opt/gdal/share/gdal /opt/gdal/share/gdal
COPY --from=builder /opt/gdal/share/proj /opt/gdal/share/proj
COPY --from=builder /opt/venv            /opt/venv

# Explicit runtime configuration — GDAL/PROJ resolve data via these.
ENV PATH="/opt/venv/bin:$PATH" \
    GDAL_DATA=/opt/gdal/share/gdal \
    PROJ_LIB=/opt/gdal/share/proj \
    LD_LIBRARY_PATH=/opt/gdal/lib \
    GDAL_PAM_ENABLED=NO \
    PORT=8080

WORKDIR /app
COPY app/ /app/

# Cloud Run sends traffic to $PORT; bind 0.0.0.0 or the container is unreachable.
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT}"]

Build and deploy against Cloud Run:

bash
export DOCKER_BUILDKIT=1
IMAGE="us-central1-docker.pkg.dev/PROJECT_ID/geo/gdal-cloudrun:3.9.0"

docker build -t "$IMAGE" .
docker push "$IMAGE"

gcloud run deploy geo-processor \
  --image "$IMAGE" \
  --region us-central1 \
  --memory 4Gi \
  --cpu 2 \
  --concurrency 8 \
  --timeout 900 \
  --no-allow-unauthenticated

Verification

Confirm the image size dropped and that GDAL resolves its data inside the slim runtime:

bash
# Compare image sizes — builder is never shipped; only 'runtime' is tagged.
docker images gdal-cloudrun --format '{{.Tag}}\t{{.Size}}'

# Exercise the runtime stage exactly as Cloud Run will, injecting PORT.
docker run --rm -e PORT=8080 gdal-cloudrun:3.9.0 \
  python -c "
import rasterio, pyproj
from pyproj import CRS
rasterio.show_versions()
print('PROJ data dir:', pyproj.datadir.get_data_dir())
print('reproject OK :', CRS.from_epsg(4326).name)
"

Expected output — note the image-size difference between a single-stage build and this multi-stage image:

code
3.9.0   318MB          # multi-stage runtime image
                       # (single-stage equivalent measured ~1.4GB)
rasterio 1.4.3
    GDAL: 3.9.0
    PROJ: 9.4.0
    GEOS: 3.12.1
PROJ data dir: /opt/gdal/share/proj
reproject OK : WGS 84

Gotchas and Edge Cases

  • Forgetting to bind $PORT makes the service unhealthy. Cloud Run marks the container failed if it does not listen on the injected PORT within the startup timeout. Always --host 0.0.0.0 --port ${PORT} — binding 127.0.0.1 or a hardcoded 8000 is the single most common Cloud Run GDAL deployment failure.

  • proj.db must be copied, not assumed. The runtime stage has no PROJ install of its own, so pyproj fails with DataDirNotFoundError unless /opt/gdal/share/proj is copied and PROJ_LIB points at it. This is exactly the kind of drift the runtime version assertion catches before traffic hits the service.

  • The read-only-ish filesystem and .aux.xml sidecars. GDAL writes .aux.xml next to rasters by default; on Cloud Run write those to a mounted /tmp or disable them with GDAL_PAM_ENABLED=NO (set above) to avoid EACCES on the image layers.

  • Cache the compiled stack to keep builds fast. The builder stage recompiles GDAL on every docker build unless BuildKit caches the layers. In CI, pair this with caching GDAL build artifacts in GitHub Actions or a registry-backed cache so unchanged versions skip the 15-minute compile.

Frequently Asked Questions

Does Cloud Run have a container image size limit for GDAL images?

Cloud Run 2nd gen does not impose a hard image-size cap the way AWS Lambda caps layers at 250 MB unzipped, but image size drives cold-start pull latency. A 1.4 GB single-stage GDAL image can add several seconds to a cold start; a ~320 MB multi-stage image pulls far faster and stays well within the 32 GB memory and 60-minute timeout envelope.

Why copy the compiled libs instead of apt-installing GDAL in the runtime stage?

Distro GDAL packages lag several minor versions behind upstream and pull dozens of -dev and X11 dependencies you never call. Compiling once in the builder and copying only the resulting .so files plus proj.db gives you an exact, pinned GDAL with a fraction of the footprint, and keeps the deployed version identical to what you tested.

What must the runtime stage still install with apt?

Only the shared runtime libraries GDAL dynamically links but cannot statically bundle — typically libcurl4, OpenSSL, libexpat1, and libtiff6, installed without their -dev variants. Everything else — compilers, cmake, headers, and the source trees — stays in the builder stage and is discarded at the COPY --from boundary.


Back to Docker Container Optimization for GIS