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.

Cold-start versus warm request lifecycle on Cloud RunThe top timeline is a cold start beginning from zero instances: Cloud Run provisions a container, starts the Python runtime, imports geopandas, rasterio and pyproj, loads a GeoDataFrame, then finally serves the request, taking several seconds. The bottom timeline is a warm request served by an instance kept alive by min-instances set to one: the imports and data are already resident, so the request is served in tens of milliseconds. A note shows that startup CPU boost shortens the import and load phases of the cold start.Cold start (scale from zero)first request pays for provisioning, imports, and data loadprovisionstart runtimeimport geo libsload GeoDataFrameserve≈ 5–15 s to first bytestartup CPU boost compresses these two phasesWarm request (min-instances = 1)libraries and data already resident on a kept-alive instanceserve≈ 20–80 ms to first byte — no import or load on the hot pathInstance scaling under loadwarm #1cold #2cold #3min-instances keeps #1 hot; concurrency decides when #2 and #3 spin up

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.32 or panel>=1.4, plus the geospatial stack: geopandas>=0.14, shapely>=2.0, pyproj>=3.6, and rasterio>=1.3 if you serve raster layers.
  • gcloud CLI 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:

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

dockerfile
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.

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

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

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

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

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

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

SettingDefaultSpatial dashboard starting pointWhy
--memory512Mi2GiRoom for a mid-sized GeoDataFrame plus conversion headroom
--concurrency808Single-process model; bounded by per-session memory
--min-instances01+Keeps the geospatial imports and warmed cache resident
--cpu12Parallel reprojection and faster cold-start imports
CPU allocationrequest-based--no-cpu-throttlingKeeps background prefetch and asyncio loops alive
Startup boostoff--cpu-boostCompresses 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