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_passdoes 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 \
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.
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.
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.
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.
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.
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.0or the container listens only on localhost.- Websockets need the upgrade headers; Streamlit pings its socket, so the default
proxy_read_timeoutdid not close it here β raise it anyway. GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIRmatters 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/healthsays the server is up, not that the app works β run a preflight beforestreamlit run.- Do not pass
--server.enableCORS=falsewith XSRF protection on: Streamlit warns that it opens the websocket to any origin. --server.maxUploadSizedefaults high; lower it if the app accepts uploads.
Internal links
- How to containerise a GIS pipeline with Docker β the batch equivalent
- How to build a small Python GIS Docker image β image size in detail
- Fixing GDAL and GeoPandas that fail in Docker β the import failures
- Fixing an app whose memory grows with every user β sizing the container
- Sharing a map app: public, private and in between β what sits in front of it
- How to cache spatial data in a map app β per-server versus per-session
- Reproducible GIS environments explained β pinning
- How to test a map app without a browser β testing before deploying
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.