Adding Health-Check Endpoints to Streamlit Spatial Dashboards
Streamlit ships no custom HTTP routes, so expose health checks either through its built-in /_stcore/health endpoint for liveness or through a small threaded HTTP server that serves /healthz and /readyz and actually probes your geo data source and tile backend.
Jump to heading Why this matters
An orchestrator decides whether to keep sending users to a pod based on what its health probes report. If the only signal is “the Python process is alive,” a Streamlit spatial dashboard can sit in the load-balancer rotation with a dead PostGIS connection or an unreachable tile backend, serving blank maps to everyone routed to it. A proper readiness check that probes the actual spatial query path lets the orchestrator pull an unhealthy replica out of rotation until it recovers. This distinction — liveness versus readiness — is what separates a self-healing deployment from one that needs a human to notice and restart it, and it feeds directly into the scaling decisions made by Kubernetes Autoscaling & Orchestration.
The diagram below shows the two probes serving different purposes against the same process.
Jump to heading Prerequisites
- Python 3.10+ with
streamlit>=1.32. geopandas>=0.14and a database driver (psycopg2-binaryorsqlalchemy>=2.0) for the readiness probe.- A deployment target that supports HTTP probes — Kubernetes or Cloud Run.
- The
http.serverandthreadingmodules from the standard library (no extra dependency).
Jump to heading Step-by-step solution
Jump to heading Step 1 — Use the built-in route for basic liveness
Streamlit already answers on /_stcore/health, returning 200 OK once the server thread is up. That is enough for a liveness probe — it proves the process has not deadlocked. Point Kubernetes at it directly:
livenessProbe:
httpGet:
path: /_stcore/health
port: 8501
initialDelaySeconds: 20
periodSeconds: 15
The catch: /_stcore/health knows nothing about your spatial backend. It returns 200 even when PostGIS is unreachable and every map is blank. Use it for liveness only, and add a real readiness check for everything that matters to a spatial workload.
Jump to heading Step 2 — Run a threaded health server for /healthz and /readyz
Because Streamlit exposes no way to register custom routes, run a minimal HTTP server in a daemon thread on a second port. It starts alongside the Streamlit script and shares the process, so it sees the same database connections and caches:
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/healthz": # liveness
self._send(200, b"ok")
elif self.path == "/readyz": # readiness
ready, detail = check_readiness()
self._send(200 if ready else 503, detail.encode())
else:
self._send(404, b"not found")
def _send(self, code: int, body: bytes):
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass # silence per-probe access logs
_health_started = False
_health_lock = threading.Lock()
def start_health_server(port: int = 8081):
"""Idempotent — safe under Streamlit's rerun model."""
global _health_started
with _health_lock:
if _health_started:
return
server = HTTPServer(("", port), HealthHandler)
threading.Thread(target=server.serve_forever, daemon=True,
name="health-server").start()
_health_started = True
Call start_health_server() at the very top of your Streamlit entrypoint. The idempotence guard is essential: Streamlit reruns the script top to bottom on every widget interaction, and a second HTTPServer(("", 8081), ...) would raise OSError: address already in use.
Jump to heading Step 3 — Make /readyz actually check the geo backend
Readiness must prove the dashboard can serve a spatial query, not just that the process is alive. Ping the spatial database with a trivial statement and open a tiny GeoDataFrame to confirm the GDAL/PROJ stack is functional — but keep it cheap so the probe itself never becomes a load source:
import geopandas as gpd
from shapely.geometry import Point
from sqlalchemy import create_engine, text
_engine = create_engine("postgresql+psycopg2://user:pass@db:5432/gis",
pool_pre_ping=True)
# Set to True once the heavy imports at module load have completed.
IMPORTS_READY = True
def check_readiness() -> tuple[bool, str]:
if not IMPORTS_READY:
return False, "warming-up"
# 1) Spatial DB reachable?
try:
with _engine.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception as exc:
return False, f"db-unreachable: {type(exc).__name__}"
# 2) Geo stack functional? Build a 1-row in-memory GeoDataFrame.
try:
gdf = gpd.GeoDataFrame(
{"id": [1]}, geometry=[Point(-0.1276, 51.5072)], crs="EPSG:4326"
)
_ = gdf.to_crs("EPSG:3857") # exercises PROJ
except Exception as exc:
return False, f"geo-stack-broken: {type(exc).__name__}"
return True, "ready"
If your maps depend on a tile backend, add a short-timeout HEAD request to one known tile URL to the same function — a 503 from readiness is the correct response when tiles are unreachable, since the dashboard cannot render.
Jump to heading Step 4 — Wire the endpoints into k8s and Cloud Run
On Kubernetes, map liveness to /healthz and readiness to /readyz. The readiness probe gets a generous initialDelaySeconds to survive the cold geopandas import:
livenessProbe:
httpGet: {path: /healthz, port: 8081}
initialDelaySeconds: 20
periodSeconds: 15
timeoutSeconds: 3
readinessProbe:
httpGet: {path: /readyz, port: 8081}
initialDelaySeconds: 35 # allow GDAL/PROJ + first DB connect
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
On Cloud Run, use a startup probe against /readyz so the instance is not sent traffic until the spatial stack is warm, and a liveness probe against /healthz:
startupProbe:
httpGet: {path: /readyz, port: 8081}
failureThreshold: 10
periodSeconds: 5
livenessProbe:
httpGet: {path: /healthz, port: 8081}
periodSeconds: 15
Expose port 8081 in the container (EXPOSE 8081) so the probes can reach the health thread alongside the Streamlit port.
Jump to heading Verification
With the container running locally, curl each endpoint and confirm the status codes differ according to backend state:
# Liveness — should be 200 as soon as the process starts
curl -fsS -o /dev/null -w "%{http_code}\n" localhost:8081/healthz
# -> 200
# Readiness — 200 only when PostGIS answers and the geo stack imports
curl -fsS -o /dev/null -w "%{http_code}\n" localhost:8081/readyz
# -> 200 (ready) or 503 (warming-up / db-unreachable)
Now simulate a backend outage by stopping the database and re-probing. Liveness must stay 200 while readiness flips to 503 — proof the two probes are decoupled:
docker stop gis-postgres
curl -o /dev/null -w "healthz=%{http_code}\n" -s localhost:8081/healthz # 200
curl -o /dev/null -w "readyz=%{http_code}\n" -s localhost:8081/readyz # 503
If both return the same code, the readiness function is not actually reaching the database — check that pool_pre_ping=True is set so a stale pooled connection is revalidated rather than silently reused.
Jump to heading Edge cases and gotchas
- Readiness versus liveness during a heavy geopandas import. The first
import geopandasin a cold container loads GDAL and PROJ, which can take 5–15 seconds. If the readiness probe runs during that window it returns503and, ifinitialDelaySecondsis too short on the liveness probe, the pod is killed and restarted in a crash loop that never completes the import. Keep liveness lenient (initialDelaySeconds: 20, highfailureThreshold) and let readiness gate traffic while the import finishes. - Probe timeouts under single-process contention. Streamlit and the health thread share one process and one GIL. A long CPU-bound geoprocessing operation on the Streamlit thread can starve the health thread, so a probe with
timeoutSeconds: 1occasionally times out and marks a healthy pod not-ready. Give probestimeoutSeconds: 3–5, and keep the readiness check I/O-bound so it yields the GIL rather than competing for CPU. - Port collision on rerun. The most common failure is
OSError: address already in use, caused by Streamlit rerunning the entrypoint and starting a secondHTTPServer. The_health_startedguard in Step 2 makes the call idempotent; never bind the health port inside a widget callback.
Jump to heading FAQ
Can I just use Streamlit's built-in /_stcore/health endpoint?
Yes for liveness, no for readiness. /_stcore/health returns 200 as soon as the Streamlit server thread is up, which makes it a fine liveness probe. It has no knowledge of whether your PostGIS connection or tile backend is reachable, so it will report healthy while every spatial query fails. Add a separate /readyz that probes the data source, as shown in Step 3, and reserve the built-in route for liveness only.
Why does my readiness probe fail for the first 30 seconds after deploy?
Importing geopandas pulls in the GDAL and PROJ shared libraries, which can take many seconds in a cold container. If the probe runs during that window the import blocks or the data-source check times out and returns 503. Give the readiness probe a longer initialDelaySeconds (35 seconds is a safe starting point), or gate /readyz behind an imports-complete flag flipped once the heavy imports at module load have finished.
Should the readiness check run a full spatial query?
No. Keep it cheap: a SELECT 1 against the spatial database plus opening a one-row in-memory GeoDataFrame is enough to prove the stack is functional. A full bounding-box query on every probe cycle adds real load and can itself time out under contention, which would pull a healthy pod out of rotation for a problem the probe created. Reserve expensive query checks for a separate synthetic monitor, not the readiness path.
Back to Monitoring & Observability in Production
Related
- Monitoring & Observability in Production — the full metrics, logs, and traces layer these probes complement
- Kubernetes Autoscaling & Orchestration — how probe results feed pod scheduling and scaling decisions
- Cloud Run & Serverless Deployment — startup and liveness probe configuration for the serverless target