How to Deploy a Python Map App with Docker

On this page

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 large. Measured, this page's app is a 771 MB image, and GDAL is not the biggest part of it
  • it works locally and fails in the container β€” usually a missing GDAL dependency or a PROJ data path
  • the reverse proxy breaks it β€” Streamlit needs a websocket, and a plain nginx proxy_pass does not forward one: measured, the page stays blank
  • memory scales with viewers, because each Streamlit session holds its own state, and every replica 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. Example 1 builds and runs the full version.

Five deployment surprises specific to a containerised map app. five surprises, five fixessymptomfixthe image is a gigabytewheels, not a system GDALloads then hangs behind nginxforward the websocket upgradekilled on a busy afternoonsize for sessions, not requestsa data refresh needs a rebuildmount the data, version the cachekeyworks locally, imports fail in theimagepin every versionThe websocket one is the commonest and looks like an application error spatialworkflow.io
None of them are visible when the app runs on a laptop.

What you get β€” from The data used on this page

1,333 NUTS 3 regions in 36 countries; share aged 65+ in 2023: 2.7–36.0%
{'intermediate': 619, 'rural': 438, 'urban': 276} 1,332 distinct names

Wheels, mounted data, websockets, sizing

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.64.0
geopandas==1.1.4
pyogrio==0.13.0
shapely==2.1.2
pydeck==0.9.3
duckdb==1.5.5

Measured in the image built from these, the wheels carry GDAL 3.12.4 with 64 drivers, and no apt-get line is needed.

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 keeps a websocket open for everything after the first page load. A plain proxy_pass forwards HTTP and drops the upgrade, and the symptom β€” measured through nginx in a browser β€” is a page that stays blank.

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 is the belt to the braces. nginx's default is sixty seconds, but Streamlit pings its websocket, so measured behind the default a socket left idle for 75 seconds stayed open and the app still answered (Example 2). Raising it costs nothing and covers a server that does not ping.

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

Streamlit holds per-session state, and every replica holds its own copy of the 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

Measured for a similar app over the same 1,333 regions on the memory page, a server held 266 MB once its data was loaded, and ten sessions each keeping a filtered copy added 186 MB. 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, and it is better than checking the port β€” but it says the server is up, not that the app works. The script only runs when a session connects, so measured, a container whose app fails on its first line still answered ok (Example 3). The preflight is what catches that.

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.

Installing the geospatial stack from wheels rather than system packages. what changed for containerised GISsystem GDALβœ•apt packages and version matchingβœ•PROJ_LIB to setβœ•a gigabyte-scale imageβœ•a different build on every base imagemanylinux wheelsβœ“pip install geopandasβœ“native libraries bundledβœ“a slim image, a few hundred MBβœ“the same build everywhereThe remaining reason for a system GDAL is a format the wheels omit β€” check first spatialworkflow.io
pyogrio, rasterio and shapely all ship their native libraries.

A small image, a proxy and a start-up check

The data used on this page

The "districts" are Europe's NUTS 3 regions β€” 1,333 of them once joined to data β€” from Eurostat GISCO at 1:1 million, with GISCO's urban–rural typology as the category, and the share of the population aged 65 and over for 2019 to 2023 from the Eurostat table demo_r_pjanind3 as rate_2019 … rate_2023. Eurostat refuses the default python-requests user agent, hence the header.

import math
import os

import geopandas as gpd
import pandas as pd
import requests

YEARS = [2019, 2020, 2021, 2022, 2023]


def eurostat(table, **filters):
    """A Eurostat table as a Series indexed by every dimension."""
    stat = requests.get(f"https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{table}",
                        params={"format": "JSON", **filters},
                        headers={"User-Agent": "spatialworkflow.io guide"}, timeout=120).json()
    codes = [sorted(stat["dimension"][d]["category"]["index"], key=stat["dimension"][d]["category"]["index"].get)
             for d in stat["id"]]
    values = [stat["value"].get(str(i)) for i in range(math.prod(stat["size"]))]
    return pd.Series(values, index=pd.MultiIndex.from_product(codes, names=stat["id"]), dtype=float)


if not os.path.exists("districts.gpkg"):
    nuts = gpd.read_file("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
                         "NUTS_RG_01M_2021_4326_LEVL_3.geojson")
    older = (eurostat("demo_r_pjanind3", indic_de="PC_Y65_MAX", unit="PC",
                      sinceTimePeriod=YEARS[0], untilTimePeriod=YEARS[-1])
             .droplevel(["freq", "indic_de", "unit"]).unstack("time"))
    older.columns = [f"rate_{year}" for year in older.columns]
    kind = {1: "urban", 2: "intermediate", 3: "rural"}
    (nuts.assign(category=nuts["URBN_TYPE"].map(kind))
         .rename(columns={"NUTS_ID": "code", "CNTR_CODE": "country", "NAME_LATN": "name"})
         [["code", "country", "name", "category", "geometry"]]
         .merge(older, left_on="code", right_index=True)
         .dropna(subset=[f"rate_{YEARS[-1]}"])
         .to_file("districts.gpkg"))

districts = gpd.read_file("districts.gpkg")
print(f"{len(districts):,} NUTS 3 regions in {districts.country.nunique()} countries; "
      f"share aged 65+ in {YEARS[-1]}: {districts[f'rate_{YEARS[-1]}'].min():.1f}–"
      f"{districts[f'rate_{YEARS[-1]}'].max():.1f}%")
print(districts.category.value_counts().to_dict(),
      f"{districts.name.nunique():,} distinct names")

# the app reads $DATA_DIR/districts.gpkg, with the latest year as `rate`
for folder in ("app", "data", "nginx"):
    os.makedirs(folder, exist_ok=True)
districts.assign(rate=districts[f"rate_{YEARS[-1]}"]).to_file("data/districts.gpkg")
1,333 NUTS 3 regions in 36 countries; share aged 65+ in 2023: 2.7–36.0%
{'intermediate': 619, 'rural': 438, 'urban': 276} 1,332 distinct names

The app is the one from How to test a map app without a browser: the logic in one module, the wiring in another, and a preflight check.

%%writefile app/logic.py
import os
import tempfile
from dataclasses import dataclass


@dataclass(frozen=True)
class Selection:
    country: str | None = None
    min_rate: float = 0.0


def apply_selection(districts, selection: Selection):
    """Pure function: the part of the app worth most of the tests."""
    subset = districts
    if selection.country:
        subset = subset[subset["country"] == selection.country]
    return subset[subset["rate"] >= selection.min_rate]


def to_csv(gdf, key: str) -> bytes:
    return gdf.drop(columns=gdf.geometry.name).to_csv(index=False).encode("utf-8")


def to_geojson_bytes(gdf) -> bytes:
    return gdf.to_json().encode("utf-8")


def to_geopackage(gdf, key: str) -> bytes:
    with tempfile.TemporaryDirectory() as directory:
        path = os.path.join(directory, "selection.gpkg")
        gdf.to_file(path, layer="selection", driver="GPKG")
        with open(path, "rb") as handle:
            return handle.read()
%%writefile app/main.py
import os

import geopandas as gpd
import pydeck as pdk
import streamlit as st

from logic import Selection, apply_selection, to_csv

DATA_DIR = os.environ.get("DATA_DIR", "data")


@st.cache_data
def load(path):
    gdf = gpd.read_file(path).to_crs(4326)
    gdf["geometry"] = gdf.geometry.simplify(0.01, preserve_topology=True)
    return gdf


st.title("Share of the population aged 65 and over")
districts = load(os.path.join(DATA_DIR, "districts.gpkg"))

country = st.selectbox("Country", ["All", *sorted(districts["country"].unique())], key="country")
threshold = st.slider("Highlight above (%)", 0.0, 40.0, 25.0, 0.5, key="threshold")

subset = apply_selection(districts, Selection(None if country == "All" else country))
above = subset[subset["rate"] > threshold]

st.metric("Regions", f"{len(subset):,}")
st.metric("Above threshold", f"{len(above):,}")
if above.empty:
    st.warning("No districts above this threshold β€” lower it to see some.")

selected = st.session_state.setdefault("selected", set())
if selected:
    st.caption(f"{len(selected)} selected")
else:
    st.info("Click a region on the map to select it.")

layer = pdk.Layer("GeoJsonLayer", subset.assign(above=subset["rate"] > threshold).__geo_interface__,
                  get_fill_color="properties.above ? [239, 68, 68, 160] : [14, 165, 233, 120]",
                  pickable=True)
st.pydeck_chart(pdk.Deck(layers=[layer], initial_view_state=pdk.ViewState(
    latitude=52.0, longitude=10.0, zoom=3)), width="stretch")
st.download_button("Download CSV", to_csv(subset, f"{country}|{threshold}"),
                   file_name="selection.csv", mime="text/csv")
%%writefile app/preflight.py
"""app/preflight.py β€” fail loudly at start-up 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()

Example 1 β€” a small, layered image

%%writefile requirements.txt
streamlit==1.64.0
geopandas==1.1.4
pyogrio==0.13.0
shapely==2.1.2
pydeck==0.9.3
duckdb==1.5.5
%%writefile Dockerfile
# 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

# the preflight runs first, so a missing mount stops the container
CMD ["sh", "-c", "python app/preflight.py && exec streamlit run app/main.py \
     --server.port=8501 --server.address=0.0.0.0 \
     --server.enableXsrfProtection=true --server.maxUploadSize=50"]

Build it twice β€” from nothing, then after a one-line change to the app β€” and look at what the image is made of:

import subprocess
import time


def sh(*args, **kwargs):
    return subprocess.run(args, capture_output=True, text=True, check=True, **kwargs).stdout


started = time.perf_counter()
sh("docker", "build", "--no-cache", "-q", "-t", "sw-guide-mapapp", ".")
full = time.perf_counter() - started

with open("app/main.py", "a") as out:
    out.write("# a code change\n")
started = time.perf_counter()
sh("docker", "build", "-q", "-t", "sw-guide-mapapp", ".")
code_only = time.perf_counter() - started

print(f"build from nothing {full:.0f} s, after a code change {code_only:.1f} s")
print("image", sh("docker", "images", "sw-guide-mapapp", "--format", "{{.Size}}").strip())
for line in sh("docker", "history", "sw-guide-mapapp", "--format", "{{.Size}}\t{{.CreatedBy}}").splitlines():
    size, step = line.split("\t", 1)
    if size.endswith("MB"):
        print(f"  {size:>7}  {step[:60]}")
print(sh("docker", "run", "--rm", "--entrypoint", "sh", "sw-guide-mapapp", "-c",
         "du -sm /usr/local/lib/python3.12/site-packages/* | sort -rn | head -4"))
print(sh("docker", "run", "--rm", "--entrypoint", "python", "sw-guide-mapapp", "-c",
         "import pyogrio; print('GDAL', pyogrio.__gdal_version_string__, len(pyogrio.list_drivers()), 'drivers')"))
build from nothing 18 s, after a code change 0.5 s
image 771MB
    651MB  RUN /bin/sh -c pip install --no-cache-dir -r…
   36.8MB  RUN /bin/sh -c set -eux;   savedAptMark="$(a…
   3.81MB  RUN /bin/sh -c set -eux;  apt-get update;  a…
   78.8MB  # debian.sh --arch 'amd64' out/ 'trixie' '@1…
156	/usr/local/lib/python3.12/site-packages/pyarrow
80	/usr/local/lib/python3.12/site-packages/pyogrio.libs
72	/usr/local/lib/python3.12/site-packages/pandas
58	/usr/local/lib/python3.12/site-packages/_duckdb.cpython-312-x86_64-linux-gnu.so

GDAL 3.12.4 64 drivers

Copying requirements.txt before the code is the layer-caching trick: the 651 MB dependency layer is reused, and only the code and the chown above it are rebuilt. The largest single package is not GDAL but pyarrow, which Streamlit depends on.

An earlier version of this Dockerfile also passed --server.enableCORS=false. Streamlit 1.64 logs a warning about that flag at start-up, and the warning is worth reading:

out = subprocess.run(["docker", "run", "--rm", "--entrypoint", "timeout", "sw-guide-mapapp", "8",
                      "streamlit", "run", "app/main.py", "--server.enableCORS=false",
                      "--server.enableXsrfProtection=true"], capture_output=True, text=True)
lines = (out.stdout + out.stderr).splitlines()
start = next(i for i, line in enumerate(lines) if "enableCORS=false' tells" in line)
print("\n".join(lines[start:start + 9]))
'server.enableCORS=false' tells Streamlit not to limit cross-origin access.
Streamlit sends the header 'Access-Control-Allow-Origin: *', except on the file
upload routes, which stay pinned to the app address and still require a valid
XSRF token.
Streamlit also accepts a WebSocket connection from any origin.
Streamlit does not set 'server.enableCORS' to 'true' for you.
'server.enableXsrfProtection=true' does not stop a cross-origin WebSocket
connection, because Streamlit does not need a valid XSRF token to open one.
What a 771 MB Streamlit map app image is made of: the Python base, the dependency layer, and the largest packages inside it. where the 771 MB goespython:3.12-slim base119 MBpip install layer651 MB of which pyarrow156 MB of which GDAL80 MB of which pandas72 MBthe app itself2.6 kB0350 MB700 MBGDAL is not the heavy part any more; the app framework's dependencies are spatialworkflow.io
Measured with docker history and du in Example 1. A code change rebuilds only the last few kilobytes.

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

%%writefile compose.yaml
services:
  app:
    build: .
    image: sw-guide-mapapp
    environment:
      DATA_DIR: /data
    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: ["127.0.0.1::80"]
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app: {condition: service_healthy}

  plain_proxy:
    image: nginx:alpine
    ports: ["127.0.0.1::80"]
    volumes:
      - ./nginx/plain.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app: {condition: service_healthy}
%%writefile nginx/default.conf
server {
    listen 80;
    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;
    }
}

The second proxy is the configuration people start with β€” proxy_pass and nothing else β€” kept alongside to show what it does:

%%writefile nginx/plain.conf
server {
    listen 80;
    location / {
        proxy_pass http://app:8501;
    }
}
sh("docker", "compose", "-p", "swguide", "up", "-d", "--wait")
ports = {name: sh("docker", "compose", "-p", "swguide", "port", name, "80").strip().split(":")[-1]
         for name in ("proxy", "plain_proxy")}
app_id = sh("docker", "compose", "-p", "swguide", "ps", "-q", "app").strip()
limit = sh("docker", "inspect", "-f", "{{.HostConfig.Memory}}", app_id).strip()
print(f"app memory limit {int(limit) / 2**30:.0f} GiB, health "
      + sh("docker", "inspect", "-f", "{{.State.Health.Status}}", app_id).strip())
app memory limit 2 GiB, health healthy

Both proxies in a real browser, and then the full one left idle for 75 seconds β€” longer than nginx's default 60-second read timeout β€” before the next interaction:

from playwright.sync_api import sync_playwright

with sync_playwright() as playwright:
    browser = playwright.chromium.launch()
    for name in ("proxy", "plain_proxy"):
        page = browser.new_page()
        page.goto(f"http://127.0.0.1:{ports[name]}/")
        try:
            page.wait_for_selector('[data-testid="stMetricValue"]', timeout=30_000)
            print(f"{name:12} app rendered: Regions = "
                  f"{page.locator('[data-testid=stMetricValue]').first.inner_text()}")
        except Exception:
            print(f"{name:12} nothing after 30 s; the page text is {page.locator('body').inner_text()!r}")
        page.close()

    page = browser.new_page()
    closed = []
    page.on("websocket", lambda socket: socket.on("close", lambda _: closed.append(time.time())))
    page.goto(f"http://127.0.0.1:{ports['proxy']}/")
    page.wait_for_selector('[data-testid="stMetricValue"]', timeout=30_000)
    time.sleep(75)
    page.get_by_role("combobox").first.click()
    page.keyboard.type("PT")
    page.keyboard.press("Enter")
    time.sleep(4)
    print(f"after 75 s idle: websocket closes {len(closed)}, Regions = "
          f"{page.locator('[data-testid=stMetricValue]').first.inner_text()}")
    browser.close()
proxy        app rendered: Regions = 1,333
plain_proxy  nothing after 30 s; the page text is ''
after 75 s idle: websocket closes 0, Regions = 25

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, above, checks the environment, the mounted files and the geospatial stack. Where it runs decides whether it does anything. Called from the first line of the app, it runs only when somebody opens the page β€” and sys.exit there ends one script run, not the server. Run before streamlit run, as the Dockerfile's CMD does, it stops the container:

with open("app/main_checked.py", "w") as out:
    out.write("from preflight import preflight\npreflight()\n" + open("app/main.py").read())
sh("docker", "build", "-q", "-t", "sw-guide-mapapp", ".")

inside = sh("docker", "run", "-d", "-e", "DATA_DIR=/data", "sw-guide-mapapp",
            "streamlit", "run", "app/main_checked.py", "--server.address=0.0.0.0").strip()
time.sleep(12)
health = sh("docker", "exec", inside, "python", "-c",
            "import urllib.request; print(urllib.request.urlopen('http://localhost:8501/_stcore/health').read().decode())")
print(f"preflight inside the app, no volume: container {sh('docker', 'inspect', '-f', '{{.State.Status}}', inside).strip()}, "
      f"health says {health.strip()!r}")
sh("docker", "rm", "-f", inside)

first = subprocess.run(["docker", "run", "--rm", "-e", "DATA_DIR=/data", "sw-guide-mapapp"],
                       capture_output=True, text=True)
print(f"preflight before streamlit, no volume: exit code {first.returncode}")
print(first.stderr.strip())
preflight inside the app, no volume: container running, health says 'ok'
preflight before streamlit, no volume: exit code 1
preflight failed:
  ! /data/districts.gpkg is missing β€” is the volume mounted?

Running it first turns "the map is empty and there is no error" into a container that refuses to start, with a message naming the missing mount.

What the health endpoint, a preflight inside the app, and a preflight before streamlit run each report when the data volume is missing. the volume is missing: who notices?preflight in the apppreflight before runcontainer stateβœ•runningβœ“exited, code 1health checkβœ•okβœ“never starts/_stcore/health reports the server, not the script spatialworkflow.io
Measured in Example 3 with no volume mounted. Only the preflight in the CMD turns a missing mount into a failed deploy.

Example 4 β€” a data refresh without a rebuild

The file_version pattern from step 6, checked with AppTest: replace the file under a running session and rerun.

%%writefile refresh_app.py
import os

import geopandas as gpd
import streamlit as st

DATA_PATH = "data/refresh.gpkg"


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


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


districts = load(DATA_PATH, file_version(DATA_PATH))
st.metric("Regions", len(districts))
from streamlit.testing.v1 import AppTest

regions = gpd.read_file("data/districts.gpkg")
regions.to_file("data/refresh.gpkg")
at = AppTest.from_file("refresh_app.py", default_timeout=60).run()
before = at.metric[0].value
regions[regions["country"] == "PT"].to_file("data/refresh.gpkg")   # the data is replaced
at.run()
print(f"before the refresh {before} regions, after {at.metric[0].value}, same session, no restart")
before the refresh 1333 regions, after 25, same session, no restart

Clean up:

sh("docker", "compose", "-p", "swguide", "down")
print(sh("docker", "image", "rm", "sw-guide-mapapp").splitlines()[0])
Untagged: sw-guide-mapapp:latest

Why wheels changed containerised GIS

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 with no apt packages at all. Measured, pyogrio's bundled GDAL is 80 MB of a 771 MB image; the rest is mostly the app framework's own dependencies.

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 β€” measured, a blank page β€” looks like an application error and is a proxy configuration. Three headers 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: measured on the memory page, each session keeping a filtered copy of 1,333 regions cost about 19 MB. 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.

The websocket path a Streamlit app needs through a reverse proxy. HTTP first, then an upgradebrowser loads theshellplain HTTPrequests anupgradeConnection: upgradeproxy must forwarditproxy_http_version 1.1and keep it openproxy_read_timeoutWithout the upgrade the shell loads and never receives content β€” a blank "Please wait…" spatialworkflow.io
The default sixty-second read timeout closes idle sockets and looks like a random disconnect.

Pinned versions, bind addresses and non-root users

  • 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 the upgrade headers; Streamlit pings its socket, so the default proxy_read_timeout did not close it here β€” raise it anyway.
  • 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 says the server is up, not that the app works β€” run a preflight before streamlit run.
  • Do not pass --server.enableCORS=false with XSRF protection on: Streamlit warns that it opens the websocket to any origin.
  • --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 β€” measured, the page stays blank. Add the Upgrade and Connection headers and proxy_http_version 1.1; raising proxy_read_timeout does no harm.

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 for the server β€” but it answered ok for a container whose app failed on its first line, because the script only runs when a session connects. Run a preflight before streamlit run to check the app's own requirements.

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. Measured, this app's image is 771 MB, of which the dependencies are 651 MB and GDAL 80 MB.