How to Deploy a Python Map App with Docker

Problem statement

A map app that runs locally has a specific set of deployment problems that a normal Python service does not:

  • the image is enormous. GDAL, PROJ, GEOS and their data files are hundreds of megabytes before your code
  • it works locally and fails in the container โ€” usually a missing GDAL dependency or a PROJ data path
  • the reverse proxy breaks it โ€” Streamlit and Panel need websockets, and a default nginx configuration does not forward them
  • memory scales with viewers, because each Streamlit session holds its own state and every worker holds its own copy of the data
  • the data is baked into the image, so a refresh means a rebuild

Each has a standard fix, and together they are the difference between a demo and something that stays up.

Quick answer

FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    STREAMLIT_SERVER_HEADLESS=true \
    STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \
    GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ ./app/

EXPOSE 8501
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s \
  CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8501/_stcore/health')"

CMD ["streamlit", "run", "app/main.py", \
     "--server.port=8501", "--server.address=0.0.0.0", \
     "--server.enableXsrfProtection=true", "--server.maxUploadSize=50"]

Two things make this smaller and more reliable than the usual first attempt: wheels rather than a system GDAL, and the data mounted rather than copied in.

Triage table of five containerised map app deployment problems and their fixes.
None of them are visible when the app runs on a laptop.

Step-by-step solution

1. Use wheels, not a system GDAL

geopandas, pyogrio, rasterio and shapely all publish manylinux wheels with their native libraries bundled. Installing them with pip needs no apt-get install gdal-bin, no version matching, and no PROJ data path fiddling.

# requirements.txt
streamlit==1.63.0
geopandas==1.1.4
pyogrio==0.13.0
shapely==2.1.2
pydeck==0.9.3
duckdb==1.5.5

Pin the versions. A map app that resolves differently next month is the commonest cause of "it worked when we deployed it".

2. Mount the data, do not bake it in

Copying a 500 MB layer into the image means a 500 MB rebuild every time the data changes, and an image that cannot be shared publicly if the data is licensed.

services:
  app:
    build: .
    volumes:
      - ./data:/data:ro
    environment:
      DATA_DIR: /data

Better still for anything large: read it from object storage or a database, so the container holds no data at all.

3. Configure the proxy for websockets

Streamlit, Panel and Bokeh all keep a websocket open. A default nginx configuration proxies HTTP and drops the upgrade, and the symptom is an app that loads and then shows "Please waitโ€ฆ" forever.

location / {
    proxy_pass http://app:8501;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 86400;
}

proxy_read_timeout matters as much as the upgrade headers: the default of sixty seconds closes an idle websocket and the app appears to disconnect at random.

4. Size the container for the sessions, not the requests

Streamlit holds per-session state, and every worker holds its own copy of module-level data. Measure the baseline and multiply:

import os

def rss_mb():
    with open("/proc/self/statm") as handle:
        pages = int(handle.read().split()[1])
    return pages * os.sysconf("SC_PAGE_SIZE") / 1e6

A 600 MB baseline with four concurrent sessions each holding a filtered copy is comfortably over 2 GB. Set the container limit above the measured peak, not above the idle figure.

5. Add a health check that means something

HEALTHCHECK CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8501/_stcore/health')"

Streamlit exposes /_stcore/health. Checking that the port is open is not the same as checking that the app runs โ€” a container whose script raises on import still holds the port.

6. Handle the data refresh without a rebuild

If the data is mounted or remote, the app only needs to notice that it changed:

import os
import streamlit as st


def file_version(path):
    stat = os.stat(path)
    return f"{stat.st_mtime_ns}:{stat.st_size}"


@st.cache_data
def load(path, version):
    import geopandas as gpd
    return gpd.read_file(path)


districts = load(DATA_PATH, file_version(DATA_PATH))

Including the version in the cache key means a replaced file invalidates the cache on the next rerun, without a restart.

Two panels contrasting a system GDAL install with manylinux wheels.
pyogrio, rasterio and shapely all ship their native libraries.

Code examples

Example 1 โ€” a small, layered image

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1 \
    GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
    STREAMLIT_SERVER_HEADLESS=true \
    STREAMLIT_BROWSER_GATHER_USAGE_STATS=false

WORKDIR /app

# dependencies first: this layer is cached until requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# then the code, which changes every build
COPY app/ ./app/

RUN useradd --create-home --uid 10001 appuser && chown -R appuser /app
USER appuser

EXPOSE 8501
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
  CMD python -c "import urllib.request; \
    urllib.request.urlopen('http://localhost:8501/_stcore/health')" || exit 1

CMD ["streamlit", "run", "app/main.py", \
     "--server.port=8501", "--server.address=0.0.0.0", \
     "--server.enableCORS=false", "--server.enableXsrfProtection=true", \
     "--server.maxUploadSize=50", "--browser.gatherUsageStats=false"]

Copying requirements.txt before the code is the layer-caching trick that turns a three-minute rebuild into a ten-second one.

Example 2 โ€” compose, with a proxy and mounted data

services:
  app:
    build: .
    environment:
      DATA_DIR: /data
      DUCKDB_PATH: /data/districts.duckdb
    volumes:
      - ./data:/data:ro
    deploy:
      resources:
        limits: {memory: 2G}
        reservations: {memory: 512M}
    healthcheck:
      test: ["CMD", "python", "-c",
             "import urllib.request;urllib.request.urlopen('http://localhost:8501/_stcore/health')"]
      interval: 30s
      timeout: 5s
      start_period: 40s
    restart: unless-stopped

  proxy:
    image: nginx:alpine
    ports: ["80:80"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app: {condition: service_healthy}

The memory limit is deliberate. Without one, a session that loads a large selection can take the host down; with one, the container is restarted and the rest of the machine survives.

Example 3 โ€” a start-up check that fails fast

"""app/preflight.py โ€” run at import; fail loudly rather than at first click."""
import os
import sys


REQUIRED_FILES = ["districts.gpkg"]
REQUIRED_ENV = ["DATA_DIR"]


def preflight():
    problems = []

    for name in REQUIRED_ENV:
        if not os.environ.get(name):
            problems.append(f"environment variable {name} is not set")

    data_dir = os.environ.get("DATA_DIR", "/data")
    for filename in REQUIRED_FILES:
        path = os.path.join(data_dir, filename)
        if not os.path.exists(path):
            problems.append(f"{path} is missing โ€” is the volume mounted?")

    try:
        import geopandas  # noqa: F401
        import pyogrio
        pyogrio.list_drivers()
    except Exception as exc:                       # noqa: BLE001
        problems.append(f"the geospatial stack failed to import: {exc}")

    if problems:
        print("preflight failed:", file=sys.stderr)
        for problem in problems:
            print(f"  ! {problem}", file=sys.stderr)
        sys.exit(1)

    print("preflight ok")


if __name__ == "__main__":
    preflight()

Running this as the first line of the app turns "the map is empty and there is no error" into a container that refuses to start with a message naming the missing mount.

Explanation

Why wheels changed containerised GIS

Installing GDAL from a distribution used to mean matching the system library against the Python bindings, setting PROJ_LIB, and an image measured in gigabytes.

The manylinux wheels for pyogrio, rasterio and shapely bundle their native libraries and data. pip install geopandas in a slim Python image now produces a working geospatial stack in a few hundred megabytes, with no apt packages at all.

The remaining reason to install a system GDAL is a format the wheels omit โ€” and that is worth checking before assuming it.

Why websockets are the commonest deployment failure

Streamlit's front end connects over HTTP and then upgrades to a websocket for everything after the first render. A proxy that forwards HTTP but not the upgrade produces an app that loads its shell and never receives content.

The symptom โ€” a blank page or a permanent "Please waitโ€ฆ" โ€” looks like an application error and is a proxy configuration. Three headers and a longer read timeout fix it.

Why memory scales with viewers rather than requests

Each Streamlit session has its own state, and anything a session holds โ€” a filtered GeoDataFrame, a selection, a cached-per-session object โ€” is per viewer.

@st.cache_data is per server, which is the mechanism for sharing the expensive parts. Anything in st.session_state is not, so a session holding a 200 MB filtered frame costs that per concurrent user. Sizing the container from the idle baseline is how apps get killed on a busy afternoon.

Why the data should not be in the image

An image containing data is a rebuild per refresh, a slower deploy, a larger registry, and a licensing problem if the image is shared.

Mounting a volume or reading from object storage separates the two lifecycles: the code deploys when it changes and the data updates when it changes. With a file version in the cache key, the running app picks up the new data without a restart.

Four-stage flow of a websocket upgrade through a reverse proxy.
The default sixty-second read timeout closes idle sockets and looks like a random disconnect.

Edge cases or notes

  • Pin every version. A map app that resolves differently next month is the usual "it worked yesterday".
  • --server.address=0.0.0.0 or the container listens only on localhost.
  • Websockets need proxy_read_timeout raised, not only the upgrade headers.
  • GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR matters when reading from object storage.
  • Run as a non-root user; it is one line.
  • Set a memory limit, or one session can take the host down.
  • /_stcore/health is Streamlit's health endpoint โ€” check it, not the port.
  • --server.maxUploadSize defaults high; lower it if the app accepts uploads.

FAQ

Do I need to install GDAL in the image?

Usually not. geopandas, pyogrio, rasterio and shapely ship manylinux wheels with their native libraries bundled, so a slim Python image and pip are enough.

Why does my app load and then hang behind nginx?

The websocket upgrade is not being forwarded. Add the Upgrade and Connection headers, proxy_http_version 1.1, and raise proxy_read_timeout.

How much memory should the container have?

Above the measured peak, not the idle baseline. Each Streamlit session holds its own state, so memory scales with concurrent viewers.

Should the data go in the image?

No. Mount it or read it from object storage, so a data refresh is not a rebuild โ€” and include a file version in the cache key so the app notices.

What should the health check test?

/_stcore/health, not the port. A container whose script raises on import still holds the port open.

How do I stop the image being enormous?

Use wheels rather than system GDAL, copy requirements.txt before the code so the dependency layer caches, and keep the data out of the image.