Deploying Spatial Dashboards to Cloud Run and Serverless Platforms
Cloud Run and comparable serverless container platforms promise a tempting deal: hand over a container, get a scalable HTTPS endpoint, and pay only while requests are in flight. That model fits stateless REST services cleanly. Spatial dashboards built on Streamlit or Panel break several of its assumptions at once — they hold long-lived WebSocket sessions, they import heavyweight geospatial libraries whose startup cost is measured in seconds, they keep large GeoDataFrame objects resident in memory, and they often run background threads that prefetch map tiles. Deploy such an app to Cloud Run with default settings and you get slow first loads, dropped sessions, tile prefetch threads that freeze between requests, and occasional Container failed to start rollbacks.
This page covers the full deployment workflow for a spatial dashboard on Cloud Run — the request-scoped versus always-on execution model, the container contract, the flags that matter for GeoDataFrame memory and single-process concurrency, and the verification steps that catch the failure modes before your users do. It sits under Deployment, Scaling & Production Operations and builds directly on Docker Containerization for Spatial Workloads, which produces the image you deploy here.
Jump to heading The request-scoped versus always-on model
The single most consequential thing to understand about Cloud Run is when your container is allowed to use CPU. In the default request-based billing model, CPU is allocated only while a request is being handled and is throttled to near zero between requests. That is invisible to a stateless API, but it is fatal to a dashboard that runs a background thread to warm a tile cache or stream a slow query: the moment the triggering request returns, the background thread is frozen mid-flight and only thaws when the next request happens to land on that instance.
The alternative is the always-allocated (instance-based) model, where CPU stays available for the full lifetime of the instance. This is what background tile prefetch, streaming responses, and any asyncio event loop that must make progress between requests actually require. It costs more because you pay for idle CPU, but for spatial dashboards it is usually the correct choice. The decision between these two models, plus how many instances you keep warm, is the backbone of every configuration flag below.
Jump to heading Prerequisites
- A working container image. Follow Docker Containerization for Spatial Workloads to build an image whose GDAL and PROJ versions are pinned, so the deployed runtime matches your development environment.
- Python 3.11+ with
streamlit>=1.32orpanel>=1.4, plus the geospatial stack:geopandas>=0.14,shapely>=2.0,pyproj>=3.6, andrasterio>=1.3if you serve raster layers. gcloudCLI authenticated to a project with Cloud Run and Artifact Registry enabled.- Familiarity with how the dashboard framework holds session state — Streamlit and Panel both keep per-user state behind a persistent WebSocket, which the session state patterns page covers in depth. That model is why session affinity is non-optional below.
Jump to heading Core implementation workflow
Jump to heading Step 1 — Satisfy the container contract
Cloud Run injects an environment variable named PORT (default 8080) and expects your process to listen on 0.0.0.0:$PORT. Binding to a fixed port or to 127.0.0.1 is the most common reason a revision never becomes healthy. Read the variable and pass it through explicitly:
# Streamlit — bind to the injected port on all interfaces
streamlit run app.py \
--server.port="${PORT:-8080}" \
--server.address=0.0.0.0 \
--server.headless=true \
--server.enableCORS=false \
--server.enableXsrfProtection=true
For Panel the equivalent start command is panel serve app.py --port ${PORT:-8080} --address 0.0.0.0 --allow-websocket-origin='*'. In both cases the process must begin accepting connections before the startup timeout elapses, or the platform kills the revision.
Jump to heading Step 2 — Write a minimal spatial Dockerfile
The image only needs to do two spatial-specific things: provide GDAL/PROJ at a pinned version and default to a start command that honours $PORT. A minimal shape:
FROM python:3.11-slim
# GDAL/PROJ come in via the geopandas wheel stack; keep the layer thin.
RUN apt-get update && apt-get install -y --no-install-recommends \
libgdal32 libproj25 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# $PORT is injected at runtime; shell form expands it.
CMD streamlit run app.py --server.port=$PORT --server.address=0.0.0.0 --server.headless=true
Use the shell form of CMD (not the exec-array form) so $PORT is expanded by the shell at container start. Pinning the exact GDAL and PROJ versions is covered in Pinning GDAL and PROJ versions in spatial Docker images.
Jump to heading Step 3 — Deploy with gcloud run deploy
Push the image to Artifact Registry, then deploy with the flags that matter for a spatial workload. The memory figure is driven by the largest GeoDataFrame you hold resident plus conversion headroom — Arrow to Pandas to GeoDataFrame can transiently triple a payload’s footprint.
gcloud run deploy spatial-dashboard \
--image=europe-west1-docker.pkg.dev/my-project/apps/spatial-dashboard:latest \
--region=europe-west1 \
--memory=2Gi \
--cpu=2 \
--concurrency=8 \
--min-instances=1 \
--max-instances=10 \
--no-cpu-throttling \
--cpu-boost \
--session-affinity \
--timeout=3600 \
--port=8080 \
--allow-unauthenticated
--min-instances=1 keeps one instance warm so routine traffic never pays the cold-start cost shown in the diagram above. --memory=2Gi gives a single mid-sized GeoDataFrame room to load and reproject without an OOM kill — for memory sizing details see Setting memory limits for geopandas containers.
Jump to heading Step 4 — Tune concurrency for the single-process model
Cloud Run’s default concurrency is 80 requests per instance, which is tuned for lightweight stateless handlers. A Streamlit app is single-process and holds a persistent WebSocket per browser session; each active session consumes memory for its session state and any GeoDataFrame it has loaded. Setting concurrency too high lets one instance accept more sessions than its memory can hold, and the instance is OOM-killed — taking every session on it down at once.
Size concurrency from the memory budget: divide the instance memory ceiling by the worst-case per-session footprint, then leave headroom. For a 2 Gi instance where each session may hold a 150 MB working set, a concurrency of 8 is a safe starting point. Pair it with session affinity so a user’s follow-up requests return to the instance that already holds their session:
# Re-tune an existing service without a full redeploy
gcloud run services update spatial-dashboard \
--region=europe-west1 \
--concurrency=8 \
--session-affinity
Session affinity is best-effort, not a guarantee, so the dashboard should still tolerate an occasional reconnect by rebuilding session state from cache rather than assuming the instance is permanent.
Jump to heading Step 5 — Keep CPU allocated for background work
--no-cpu-throttling switches the service to the always-allocated model so background threads keep running between requests. This is required if the dashboard warms an in-memory tile cache on startup or runs an asyncio loop for async data loading patterns that prefetch map tiles while the user pans. --cpu-boost grants extra CPU during container startup, which measurably shortens the import-and-load phases of a cold start — the exact phases highlighted in the diagram.
# app.py — warm an in-memory tile cache once per instance, at startup.
# With --no-cpu-throttling this thread keeps running between requests.
import threading
import streamlit as st
_TILE_CACHE: dict[tuple[int, int, int], bytes] = {}
def _warm_tiles() -> None:
# Prefetch the default viewport's tiles so the first user paint is instant.
for z, x, y in [(12, 2148, 1416), (12, 2149, 1416), (12, 2148, 1417)]:
_TILE_CACHE[(z, x, y)] = _fetch_tile(z, x, y) # your ZXY fetch, EPSG:3857
@st.cache_resource
def start_warmup() -> bool:
threading.Thread(target=_warm_tiles, daemon=True).start()
return True
start_warmup() # runs once per instance thanks to @st.cache_resource
Jump to heading Step 6 — Verify startup, scaling, and session stickiness
Before routing real traffic, confirm the three things Cloud Run silently gets wrong: the container answers on $PORT, a session stays pinned, and cold versus warm latency is what you expect. The verification section below shows the exact checks.
Jump to heading Advanced patterns
Jump to heading Min-instances plus concurrency tuning as one lever
Cold-start avoidance and per-instance density are two ends of the same lever. Raising --min-instances keeps more warm capacity permanently (higher cost, zero cold starts for that many concurrent sessions); raising --concurrency packs more sessions onto each instance (lower cost, higher OOM risk). Tune them together: pick the concurrency your memory budget allows, then set min-instances to cover your steady-state concurrent session count divided by that concurrency. A team dashboard that typically has 20 people online at a concurrency of 8 needs roughly three warm instances (--min-instances=3) to serve everyone without a cold start.
Jump to heading Streaming responses for large layers
When a single map layer serializes to tens of megabytes, buffering the whole response blows past both the memory budget and Cloud Run’s non-streaming response cap. Stream the payload as chunked GeoJSON features instead of building one giant string in memory — the dedicated walkthrough in Streaming large GeoJSON responses from Cloud Run covers the generator, chunked transfer, and gzip mechanics.
Jump to heading In-memory tile cache warmed on startup
The startup warm-up in Step 5 only pays off if the instance stays alive to serve later requests, which is exactly what --min-instances and --no-cpu-throttling guarantee together. Keep the warmed cache small and bounded — a handful of default-viewport tiles, not the whole world — and let interactive panning fall through to the async tile loader. The cold-start mitigation angle of this pattern is treated in full in Mitigating Cloud Run cold starts for Streamlit spatial dashboards.
Jump to heading Verification and testing
Confirm the container contract first — a revision that never listens on $PORT will not even deploy:
# Reproduce Cloud Run's contract locally before pushing.
docker run --rm -e PORT=8080 -p 8080:8080 spatial-dashboard:latest &
sleep 8
curl -sf http://localhost:8080/_stcore/health && echo "OK: listening on \$PORT"
Measure the cold versus warm gap so you know whether min-instances is doing its job. Force a cold path by scaling to zero, then time two consecutive requests:
import time
import urllib.request
URL = "https://spatial-dashboard-xxxx.a.run.app/_stcore/health"
def timed_get(url: str) -> float:
start = time.perf_counter()
with urllib.request.urlopen(url, timeout=30) as resp:
resp.read()
return (time.perf_counter() - start) * 1000
cold_ms = timed_get(URL) # first hit after idle — may pay cold start
warm_ms = timed_get(URL) # immediate follow-up — should be warm
print(f"cold={cold_ms:.0f}ms warm={warm_ms:.0f}ms")
# With min-instances>=1 you should NOT see a multi-second cold_ms.
assert warm_ms < 500, "Warm request is slow — check CPU allocation and concurrency"
Check session stickiness by hitting the service repeatedly with an affinity cookie and confirming the same instance ID answers:
# Cloud Run sets an instance id header when session affinity is on.
for i in 1 2 3; do
curl -s -c jar.txt -b jar.txt -D - -o /dev/null \
https://spatial-dashboard-xxxx.a.run.app/ | grep -i "x-cloud-run"
done
# All three responses should report the same backing instance.
Jump to heading Troubleshooting
Container failed to start and listen on the port defined by the PORT environment variable : The dashboard bound to a hardcoded port or to 127.0.0.1. Start Streamlit with --server.port=$PORT --server.address=0.0.0.0 (Panel: --port $PORT --address 0.0.0.0) and use the shell form of CMD so the variable expands. Also confirm the app finishes importing geopandas and friends before the startup timeout — heavy module-level imports can push first-listen past the deadline.
429 with The request was aborted because there was no available instance : Traffic exceeded max-instances × concurrency or instances were still cold-starting. Raise --max-instances, raise --concurrency if memory allows, and set --min-instances high enough to absorb the baseline. This error is a capacity signal, not a code bug.
First request after idle takes 5–15 seconds : A cold start importing the geospatial stack and loading a GeoDataFrame on the hot path. Set --min-instances=1, enable --cpu-boost, and defer the data load behind a cached function so it is not on the import path — see the cold-start walkthrough for the full sequence.
Background tile prefetch stops making progress between requests : CPU is throttled in the default request-based model, so the background thread freezes the moment the request returns. Redeploy with --no-cpu-throttling to move to the always-allocated model.
WebSocket connection fails or the map never renders after a few seconds : Either session affinity is off and follow-up requests hit an instance with no matching session, or the ingress path is stripping the WebSocket upgrade. Enable --session-affinity, raise --timeout above the default, and confirm no intermediate proxy blocks Connection: Upgrade.
Jump to heading Performance considerations
Right-size memory before concurrency. A spatial dashboard’s memory ceiling, not its CPU, is almost always the binding constraint. Establish the worst-case per-session GeoDataFrame footprint first, then set concurrency from it. Over-provisioning concurrency to save money is the fastest route to OOM-driven revision instability.
Cold-start cost is dominated by imports, not code. Importing geopandas, rasterio, and pyproj can take one to three seconds before your first line of application logic runs. --cpu-boost shortens this, lazy imports shorten it further, and --min-instances removes it from the hot path entirely.
CPU model changes the billing shape. --no-cpu-throttling bills for allocated CPU across the instance lifetime, not just during requests. For a low-traffic internal tool this can cost more than the requests warrant; weigh it against the value of background prefetch and streaming. Scale-to-zero remains valid when a first-request cold start is acceptable.
| Setting | Default | Spatial dashboard starting point | Why |
|---|---|---|---|
--memory | 512Mi | 2Gi | Room for a mid-sized GeoDataFrame plus conversion headroom |
--concurrency | 80 | 8 | Single-process model; bounded by per-session memory |
--min-instances | 0 | 1+ | Keeps the geospatial imports and warmed cache resident |
--cpu | 1 | 2 | Parallel reprojection and faster cold-start imports |
| CPU allocation | request-based | --no-cpu-throttling | Keeps background prefetch and asyncio loops alive |
| Startup boost | off | --cpu-boost | Compresses the import and data-load phases of a cold start |
Jump to heading Frequently asked questions
Why does Cloud Run report the container failed to start and listen on PORT?
The dashboard bound to a hardcoded port or to 127.0.0.1 instead of the $PORT value Cloud Run injects on 0.0.0.0. Start Streamlit with --server.port=$PORT --server.address=0.0.0.0 and Panel with --port $PORT --address 0.0.0.0, using the shell form of CMD so the variable expands. The container must begin accepting connections within the startup timeout, so make sure heavy geopandas and rasterio imports are not delaying first-listen past the deadline.
Does scale-to-zero make sense for a spatial dashboard?
Only for internal or low-traffic tools where a five-to-fifteen second cold start on the first request is acceptable. Importing geopandas, rasterio, and pyproj plus loading a GeoDataFrame dominates that cold start. For dashboards with steady interactive traffic, set --min-instances to 1 or higher so at least one warm instance always holds the imported libraries and any startup-warmed tile cache.
Why do users lose their session or see a blank map on Cloud Run?
Streamlit and Panel keep per-user state over a persistent WebSocket. Without session affinity, Cloud Run’s load balancer can route follow-up requests from the same browser to a different instance that has no matching session, so the connection drops and the map fails to render. Enable --session-affinity and make sure your ingress and any proxy allow WebSocket upgrades and a long enough --timeout.
Back to Deployment, Scaling & Production Operations
Related
- Docker Containerization for Spatial Workloads — builds the pinned GDAL/PROJ image you deploy to Cloud Run
- Mitigating Cold Starts for Streamlit Spatial Dashboards — min-instances, lazy imports, and startup pre-warming in depth
- Streaming Large GeoJSON Responses from Cloud Run — chunked, gzipped feature streaming past the 32MB response cap
- Async Data Loading Patterns — non-blocking tile prefetch that relies on always-allocated CPU