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.


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.

Single-stage against multi-stage image size for a GDAL Cloud Run serviceThree measured sizes: a single-stage image built with apt gdal-bin and pip install at about 1.4 gigabytes, the multi-stage runtime image from this page at 318 megabytes, and the AWS Lambda 250 megabyte unzipped layer ceiling shown for scale.Image size for the same rasterio + pyproj Cloud Run serviceSingle-stage apt + pip build~1.4 GBMulti-stage runtime image318 MBAWS Lambda layer ceiling250 MB0MB, uncompressedThe Lambda ceiling is drawn only for scale — it does not apply here. On Cloud Run the cost of size is registry pull time on every cold start, paidin CPU that is billed from request receipt.
Cloud Run enforces no image-size cap, so nothing rejects the 1.4 GB build — it is simply pulled again on every cold start of every new instance.

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.

Builder stage, COPY boundary and slim runtime stage for Cloud RunFive stages left to right: a builder stage on python:3.11-bookworm carrying the full toolchain, compilation of PROJ 9.4.0 then GEOS 3.12.1 then GDAL 3.9.0, symbol stripping and construction of the virtual environment, a runtime stage on python:3.11-slim receiving only the shared objects, data directories and virtual environment, and Cloud Run pulling the resulting 318 megabyte image and binding the injected PORT.Builder stagepython:3.11-bookwormgcc, cmake, -devCompile the stackPROJ 9.4 to GEOS3.12then GDAL 3.9.0Strip and venv--strip-unneededpip into /opt/venvRuntime stagepython:3.11-slim.so + proj.db + venvCloud Runbinds $PORT = 8080~318 MB pulled
Only stripped shared objects, the GDAL and PROJ data directories and /opt/venv cross the COPY --from=builder boundary. The compilers, headers and source archives stay in the builder and are never pushed to Artifact Registry.
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
Composition of the multi-stage Cloud Run runtime imageSix slices of the 318 megabyte runtime image: 125 megabytes of python:3.11-slim base, 96 megabytes of stripped shared objects under /opt/gdal/lib, 52 megabytes of virtual environment, 38 megabytes of GDAL and PROJ data directories, 4 megabytes of apt runtime libraries, and 3 megabytes of application code.The 318 MB that Artifact Registry actually storespython:3.11-slim basethe floor for a glibc runtime with a working CPython125 MB/opt/gdal/lib — stripped shared objectslibgdal, libproj and libgeos after strip --strip-unneeded in the builder96 MB/opt/venvrasterio 1.4.3, pyproj 3.7.0, shapely 2.0.6, fastapi and uvicorn52 MB/opt/gdal/share — gdal and proj dataproj.db and driver metadata; PROJ_LIB points here or pyproj raises DataDirNotFoundError38 MBapt runtime librarieslibcurl4, libexpat1 and libtiff6 — no -dev variants4 MBApplication codethe ASGI app copied into /app3 MBNothing from the builder stage appears in this list: no gcc, no cmake, no -dev headers, no source archives. That absence is the entiredifference between 1.4 GB and 318 MB.
The Debian-slim base and the compiled GDAL libraries are two thirds of the image, so any further trimming has to come from one of those two — not from the application.

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