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 \
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.
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.
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 \
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.
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.0or the container listens only on localhost.- Websockets need
proxy_read_timeoutraised, not only the upgrade headers. 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/healthis Streamlit's health endpoint โ check it, not the port.--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. 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.