Packaging a spatial dashboard into a container looks deceptively simple until the first docker build produces a 2.4 GB image that takes four minutes to push and thirty seconds to cold-start. The weight is not your code — it is the native geospatial stack. geopandas, rasterio, fiona, and pyproj all bind to compiled libraries: GDAL translates dozens of raster and vector formats, PROJ performs coordinate transformations backed by data grids, and GEOS evaluates geometry predicates. Install them carelessly and you ship the compilers, the header packages, the apt caches, and every format driver you will never use.

This page is the workflow for building a lean, reproducible Docker image for a Streamlit or Panel spatial dashboard that still carries a fully working GDAL and PROJ stack. It sits under Deployment, Scaling & Production Operations and feeds directly into how the resulting image behaves at runtime — its memory ceiling, covered in memory limit management, and the reproducibility of its coordinate transformations.


Jump to heading Prerequisites

Confirm the following before building:

  • Docker 24+ with BuildKit enabled (DOCKER_BUILDKIT=1) for build-cache mounts and multi-stage optimisation.
  • A pinned requirements file listing streamlit>=1.32 or panel>=1.3, geopandas>=0.14, shapely>=2.0, rasterio>=1.3, pyproj>=3.6, and fiona>=1.9.
  • Familiarity with the geospatial import graph — that geopandas pulls in pyproj, shapely, and fiona, and that these load GDAL, PROJ, and GEOS at import time.
  • An understanding of the runtime memory profile of your largest GeoDataFrame, since the container limit derives from it — see memory limit management.

Jump to heading How a multi-stage build shrinks the image

The core technique is a multi-stage build. A fat builder stage installs the compilers and -dev header packages needed to build wheels; a slim runtime stage receives only the finished Python packages and the runtime native libraries, discarding the toolchain entirely. The diagram shows which layers cross the stage boundary and which are thrown away.

Multi-stage build for a spatial dashboard imageLeft column is the builder stage containing the base image, build-essential compilers, GDAL and PROJ dev headers, and the pip-installed geo wheels. A copy arrow crosses to the right column, the runtime stage, which contains a slim base image, the GDAL and PROJ runtime packages, and the copied Python packages plus application code. The compilers and dev headers do not cross and are discarded, so the runtime image is much smaller.BUILDER STAGEpython:3.12-slim basebuild-essentialdiscarded ✕libgdal-dev · libproj-devdiscarded ✕pip wheelsgeopandas · rasteriofiona · pyprojCOPY --from=builderRUNTIME STAGE (shipped)python:3.12-slim basegdal-bin · proj-binruntime libs onlycopied Python packagesno compilersapp code · non-root user

Jump to heading Core implementation workflow

Jump to heading Step 1 — Choose a base image

Two viable bases exist for a spatial dashboard, and the choice is a version-control decision.

  • python:3.12-slim plus apt geospatial packages. Smallest reproducible footprint. You accept the GDAL and PROJ versions your Debian release ships (currently GDAL 3.6 on Bookworm). Best when the distribution version is acceptable.
  • A conda or micromamba base. Larger, but lets you pin an exact GDAL or PROJ build from conda-forge independent of the OS. Best when a specific version is mandatory or when a dependency only ships reliable binaries through conda.

For most internal dashboards the slim + apt path wins on size and build speed. Reach for conda only when you need a version apt cannot give you — the trade-offs matter enough that they are covered in pinning GDAL and PROJ versions in spatial Docker images.

Jump to heading Step 2 — Write the multi-stage build

The builder stage installs -dev header packages so wheels compile against the system GDAL; the runtime stage installs only the runtime libraries and receives the built packages.

dockerfile
FROM python:3.12-slim AS builder
ENV PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential gdal-bin libgdal-dev proj-bin libproj-dev libgeos-dev \
    && rm -rf /var/lib/apt/lists/*
ENV GDAL_CONFIG=/usr/bin/gdal-config
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt

FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
        gdal-bin proj-bin proj-data libgeos-c1v5 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=builder /install /usr/local

The runtime stage deliberately keeps gdal-bin, proj-bin, and libgeos-c1v5 — the shared objects that fiona and rasterio dynamically load — while dropping build-essential and the -dev headers that only mattered at compile time.

Jump to heading Step 3 — Order layers so geo wheels stay cached

Docker rebuilds a layer only when its inputs change. Copy requirements.txt and run pip install before copying application source, so an edit to your dashboard code does not invalidate the expensive geo-wheel install:

dockerfile
# In the builder stage, dependencies BEFORE source:
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
# Application code changes often; keep it in a later, cheap layer:
COPY app/ /app/

With a BuildKit cache mount you can additionally persist pip’s download cache across builds:

dockerfile
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --prefix=/install -r requirements.txt

Jump to heading Step 4 — Run as non-root with a healthcheck

Never run a public-facing dashboard as root. Create an unprivileged user, set PROJ_DATA and GDAL_DATA explicitly, and add a HEALTHCHECK that hits Streamlit’s health path:

dockerfile
FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
        gdal-bin proj-bin proj-data libgeos-c1v5 curl \
    && rm -rf /var/lib/apt/lists/* \
    && useradd --create-home --uid 10001 appuser
COPY --from=builder /install /usr/local
COPY --chown=appuser:appuser app/ /app/
ENV PROJ_DATA=/usr/share/proj \
    GDAL_DATA=/usr/share/gdal \
    PYTHONUNBUFFERED=1
WORKDIR /app
USER appuser
EXPOSE 8501
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
  CMD curl -fsS http://localhost:8501/_stcore/health || exit 1
CMD ["streamlit", "run", "dashboard.py", \
     "--server.port=8501", "--server.address=0.0.0.0"]

The --start-period=40s grace window matters for spatial images: the first import of geopandas and the first read of proj.db can take several seconds, and without a start period the healthcheck fails the container before it has finished waking up.

Jump to heading Step 5 — Set memory and CPU limits and run with compose

Run the image with explicit limits so a runaway GeoDataFrame load cannot exhaust the host:

bash
docker run --rm \
  --memory=3g --memory-swap=3g --cpus=2 \
  -e MALLOC_ARENA_MAX=2 \
  -p 8501:8501 \
  spatial-dashboard:latest

Or declare the same in a compose file for a reproducible local and staging run:

yaml
services:
  dashboard:
    image: spatial-dashboard:latest
    ports:
      - "8501:8501"
    environment:
      MALLOC_ARENA_MAX: "2"
      PROJ_DATA: /usr/share/proj
    deploy:
      resources:
        limits:
          memory: 3g
          cpus: "2.0"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8501/_stcore/health"]
      interval: 30s
      timeout: 5s
      retries: 3

Choosing that 3g figure from a measured resident set — rather than guessing — is the whole subject of setting memory limits for GeoPandas containers.


Jump to heading Advanced patterns

Jump to heading Trimming GDAL format drivers

A default GDAL install carries drivers for scores of formats your dashboard will never open. If your data is only GeoParquet and GeoJSON, you can build GDAL with a reduced driver set or, more simply, avoid the full gdal-bin metapackage and install only the libraries the Python wheels need. On conda you can select libgdal-core instead of the full libgdal to drop optional format plugins and shave a few hundred megabytes.

Jump to heading Pre-warming the PROJ database into a layer

The first coordinate transformation opens proj.db and, on some builds, triggers a lookup that reads several data grids. You can warm this at build time so the shipped image has already validated its PROJ data directory:

dockerfile
RUN python -c "import pyproj; \
    pyproj.Transformer.from_crs('EPSG:4326','EPSG:3857',always_xy=True).transform(0,51.5)"

A build that fails on this line tells you the PROJ data grids are missing before the image ever reaches production, converting a silent runtime fault into a loud build failure.

Jump to heading Separate images for the app and heavy raster processing

If your dashboard occasionally runs heavyweight rasterio reprojection or datashader aggregation, keep that work in a sidecar image with its own limits rather than inflating the interactive app image. The dashboard stays small and fast to scale; the raster worker scales on its own schedule. This mirrors the separation of interactive and background work described in Deployment, Scaling & Production Operations.


Jump to heading Verification and testing

Prove the native stack actually works inside the built image, not just that the image built:

bash
docker run --rm spatial-dashboard:latest \
  python -c "import geopandas, rasterio, pyproj; \
             from osgeo import gdal; \
             print('gdal', gdal.__version__); \
             print('proj', pyproj.proj_version_str); \
             g = geopandas.GeoSeries.from_wkt(['POINT (-0.1276 51.5072)']).set_crs('EPSG:4326'); \
             print('reproject', g.to_crs('EPSG:27700').iloc[0])"

A healthy run prints the GDAL and PROJ versions and a British National Grid coordinate near (530000, 180000). Check the final image size and confirm the container runs unprivileged:

bash
docker images spatial-dashboard:latest              # read the SIZE column
docker run --rm spatial-dashboard:latest id -u      # expect 10001, not 0

Jump to heading Troubleshooting

ImportError: libgdal.so: cannot open shared object file: No such file or directory : The runtime stage copied the Python wheels but not the GDAL runtime library. Add gdal-bin (and on some bases libgdal-dev’s runtime counterpart) to the apt-get install in the final stage. The wheels link against libgdal.so at import time and cannot find it otherwise.

PROJ: proj_create: Cannot find proj.db : The PROJ data directory is missing or PROJ_DATA points at the wrong path. Install proj-data, set ENV PROJ_DATA=/usr/share/proj, and verify with docker run --rm image proj. This is distinct from a version mismatch — see pinning GDAL and PROJ versions for the case where proj.db exists but disagrees with the linked PROJ version.

Container exits with code 137 (OOMKilled) : The process exceeded its memory limit while loading or reprojecting a GeoDataFrame. Raise --memory only after measuring the real peak, and set MALLOC_ARENA_MAX=2 to curb glibc arena fragmentation. Full method in setting memory limits for GeoPandas containers.

Healthcheck marks the container unhealthy during startup : The geopandas import and first proj.db read exceeded the healthcheck window. Add --start-period=40s so the probe does not run until the app has finished importing the geospatial stack.

pip install recompiles GDAL bindings on every build : You copied application source before requirements.txt, invalidating the pip layer on each edit. Reorder so dependencies install before source is copied, and use a BuildKit cache mount for the pip download cache.


Jump to heading Performance considerations

Image size drives scaling latency. Every second saved on image pull is a second shaved off cold start and off each autoscaler-triggered replica launch. A multi-stage build that lands under 900 MB pulls roughly three times faster than a 2.5 GB single-stage image on a typical registry link.

Layer cache hit rate drives build speed. Keeping the geo-wheel install in a stable early layer means routine code changes rebuild in seconds instead of reinstalling geopandas and rasterio from scratch.

MALLOC_ARENA_MAX curbs phantom memory growth. glibc allocates per-thread arenas, and Streamlit’s threaded session model can spawn many, inflating resident set well beyond live objects. Capping arenas to 2 keeps the measured footprint close to the working set, which lets you set a tighter, safer container limit.

Startup time is part of scaling cost. Each new replica pays the geospatial import graph — geopandas pulling in pyproj, shapely, and fiona, plus the first proj.db read — before it can serve a session. On a busy day the autoscaler launches replicas repeatedly, so a slow import multiplies across every scale-up event. Pre-warming the PROJ database into a build layer, as shown above, and keeping the image small so the pull completes quickly both shave seconds off the interval between an autoscaler decision and a replica actually accepting traffic. Measure that interval directly rather than assuming it is negligible; for a heavy single-stage image it can dominate the response to a traffic burst.


Jump to heading Frequently asked questions

Should I use python-slim with apt geospatial packages or a conda base image?

Use python-slim with apt-installed gdal-bin, libgdal-dev, and proj packages when you want the smallest reproducible image and can accept the GDAL version your distribution ships. Use a conda or micromamba base when you need a specific GDAL or PROJ version that apt does not provide, or when a dependency only ships reliable binaries through conda-forge. Conda images are larger but decouple the geospatial version from the OS release. The version-control angle is expanded in pinning GDAL and PROJ versions.

Why does my image work locally but throw libgdal.so errors in production?

The multi-stage build copied the installed Python wheels into the runtime stage but not the GDAL runtime shared libraries they link against. fiona and rasterio dynamically load libgdal.so at import time, so the runtime stage must apt-install gdal-bin and proj-bin even though it does not need the -dev header packages. Install the runtime native libraries in the same final stage where you copy the Python packages.

How do I keep Docker from rebuilding the geopandas layer on every code change?

Copy requirements.txt and run pip install before copying your application source. Docker caches layers by their inputs, so while requirements.txt is unchanged the expensive geo-wheel install layer is reused and only the cheap application-copy layer rebuilds. Copying the whole project first invalidates the pip layer on every edit and forces a full reinstall of geopandas, rasterio, and their dependencies.


Back to Deployment, Scaling & Production Operations

Related