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.

Liveness versus readiness probes for a Streamlit spatial dashboardOn the left, a kubelet issues two probes. The liveness probe hits /healthz, which only confirms the process thread is running; failing it restarts the pod. The readiness probe hits /readyz, which checks the PostGIS connection and the tile backend; failing it removes the pod from the service load balancer but does not restart it. Both probes reach the same dashboard process on the right, which holds a health server thread and the spatial backends.kubeletprobe schedulerGET /healthzLivenessprocess alive? fail → restartGET /readyzReadinessbackend ok? fail → drop trafficDashboard processhealth thread :8081streamlit :8501PostGIS · tile backend

Jump to heading Prerequisites

  • Python 3.10+ with streamlit>=1.32.
  • geopandas>=0.14 and a database driver (psycopg2-binary or sqlalchemy>=2.0) for the readiness probe.
  • A deployment target that supports HTTP probes — Kubernetes or Cloud Run.
  • The http.server and threading modules 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:

yaml
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:

python
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:

python
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:

yaml
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:

yaml
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:

bash
# 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:

bash
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 geopandas in a cold container loads GDAL and PROJ, which can take 5–15 seconds. If the readiness probe runs during that window it returns 503 and, if initialDelaySeconds is 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, high failureThreshold) 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: 1 occasionally times out and marks a healthy pod not-ready. Give probes timeoutSeconds: 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 second HTTPServer. The _health_started guard 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