Geospatial Dashboards & Deploying Python Map Apps

A map that only renders in a notebook cell is an analysis, not a product. This stage of Web Mapping & Interactive Visualization covers the step after the map works: choosing an app shape, keeping the payload small enough for a browser, and deploying the result so it survives real traffic. It sits above the rendering layers — Interactive Maps with Folium for Leaflet-backed HTML and MapLibre GL Vector Web Maps for GPU-rendered vector styles — and consumes the output of Vector Tile Pipelines with PMTiles whenever the data outgrows a single file download. Four focused walkthroughs sit beneath it: building a Streamlit map dashboard with Folium, serving raster tiles from FastAPI with TiTiler, Streamlit vs Dash for geospatial dashboards, and deploying a Python map app behind a CDN.

Architecture & Data Structures

Every deployed Python map collapses into one of three shapes, and the shape is a consequence of where the work happens on each user interaction.

Real deployments usually combine two of them: a dashboard container for the controls and a tile service (or a static tile archive) for the pixels. The dashboard sends the browser a reference to the tile endpoint rather than the geometry itself, which is what keeps the dashboard's payload flat as the dataset grows.

The state each shape holds is worth naming explicitly, because it determines how the thing scales. A static export holds none — the deployable artifact is a directory. A dashboard holds two distinct things that are easy to conflate: session state, a per-user dictionary of widget values keyed to one open browser tab, and the process cache, a per-container store of expensive results shared by every session that container serves. A tile service holds neither; its only long-lived object is a connection pool or a GDAL dataset handle, which is why it can be replicated freely. When a dashboard suddenly needs more than one replica, it is the session state — not the cache — that forces sticky routing.

# The three shapes, reduced to their entry points.
# 1. Static export — Python runs once, writes a file, then exits.
import folium

m = folium.Map(location=[45.07, 7.69], zoom_start=12, tiles="CartoDB positron")
m.save("dist/index.html")          # upload dist/ to object storage; no server

# 2. Dashboard app — a process that reruns on interaction.
#    streamlit run app.py --server.address=0.0.0.0 --server.port=8501

# 3. Tile / data service — a stateless ASGI app.
#    uvicorn tiles:app --host 0.0.0.0 --port 8000 --workers 4
Deployment topology and cache hops A request leaves the browser, hits a CDN edge, then reaches an application container that holds both a Streamlit or Dash app and a FastAPI tile service, which in turn reads from object storage. Under each hop is the cache that lives there: the browser honours Cache-Control max-age, the CDN honours s-maxage, the container keeps an in-process cache through st.cache_data or lru_cache, and object storage serves immutable objects with ETags over range requests. Request path — and what is cached at each hop Browser map client HTTP cache CDN edge tiles + assets shared cache App container Streamlit / Dash session state FastAPI tiles stateless workers Object storage COG / PMTiles GeoParquet Private cache max-age=300 Shared cache s-maxage=86400 In-process cache cache_data / lru_cache Immutable objects ETag + Range
Four hops, four independent caches — a tile that is cheap at the edge is one you never have to make cheap in Python.

Environment Configuration & Dependency Resolution

Dashboard and service stacks pin differently, so keep them in separate requirement sets even when they ship in the same repository. A tile service should not carry Streamlit, and a dashboard should not carry GDAL if it never opens a raster.

# Dashboard container
pip install "streamlit>=1.37" "streamlit-folium>=0.22" "folium>=0.17" \
            "geopandas>=1.0" "pyogrio>=0.9"

# Tile / data service container
pip install "fastapi>=0.110" "uvicorn[standard]>=0.29" \
            "sqlalchemy>=2.0" "psycopg[binary]>=3.1" "titiler.core>=0.18"

Three pins carry real behaviour rather than bug fixes. streamlit>=1.37 is the first release where st.fragment is stable rather than experimental, and fragments are the main tool for containing rerun cost. streamlit-folium>=0.22 supplies st_folium(..., use_container_width=True) and the returned_objects argument that decides which map events trigger a rerun. geopandas>=1.0 makes pyogrio the default I/O engine, which matters here because container cold-start time is dominated by the first read of the dataset.

Two environment settings belong in the image rather than in code. Streamlit's file watcher is useless in a container and burns CPU proportional to the size of site-packages, and any service reading Cloud-Optimized GeoTIFFs over HTTP needs GDAL told not to list the whole bucket prefix on every open:

# Dashboard image
ENV STREAMLIT_SERVER_FILE_WATCHER_TYPE=none \
    STREAMLIT_SERVER_HEADLESS=true \
    STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
HEALTHCHECK CMD curl -fs http://localhost:8501/_stcore/health || exit 1
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"]
# Raster tile image — the standard GDAL/VSI tuning for remote COGs
ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
    CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif,.TIF,.tiff" \
    GDAL_HTTP_MULTIPLEX=YES \
    VSI_CACHE=TRUE \
    VSI_CACHE_SIZE=536870912

Omitting GDAL_DISABLE_READDIR_ON_OPEN is the single most common cause of a raster endpoint that works locally against a file path and then takes several seconds per tile against object storage, because GDAL issues a directory listing before every open.

Vectorized Operations & Core Workflow

One number decides the architecture before any code is written: the bytes the browser must download to draw one viewport. Under roughly two megabytes, inline GeoJSON is simpler than anything else and you should ship it. Above that, the browser spends longer parsing JSON and building DOM or GPU buffers than it does downloading, and the fix is not a faster server — it is sending less, which means tiles.

Choosing an app shape from payload size and interactivity A decision tree starting from the question of how large one viewport's payload is. Under about two megabytes, ship GeoJSON inline and render it with Folium inside a dashboard. Between two megabytes and a gigabyte, pre-build vector tiles as PMTiles on object storage and render them with MapLibre as a static export behind a CDN. For rasters or queries that must be evaluated live, render tiles on demand from a FastAPI or TiTiler container. A closing note warns that any interaction which must execute Python per click rules out a static export regardless of payload size. How big is one viewport? features × vertices, after simplify Under ~2 MB Inline GeoJSON Folium / Leaflet layer no tile build step Dashboard container 2 MB – 1 GB, static Pre-built vector tiles PMTiles on object storage MapLibre reads ranges Static export + CDN Rasters or live queries Render on demand TiTiler / ST_AsMVT cache the tile bytes Tile service container Interaction that must run Python per click — a database filter, a model, a reprojection — rules out a static export whatever the payload size.
Payload size picks the delivery mechanism; the need to execute Python per interaction picks whether a container is involved at all.

Before conceding that a dataset is too big, shrink it — the reductions are vectorized and usually cut the payload by an order of magnitude. Drop every attribute the map does not draw or label, simplify in a metric CRS with a tolerance tied to the zoom you actually publish, and round coordinates: six decimal places in EPSG:4326 is about 11 cm, and anything beyond that is bytes spent on noise.

import geopandas as gpd
import shapely

parcels = gpd.read_file("parcels.gpkg", columns=["parcel_id", "land_use"])

# 1. Simplify in metres, not degrees — reproject to the local UTM zone first.
utm = parcels.estimate_utm_crs()
parcels_m = parcels.to_crs(utm)
parcels_m["geometry"] = parcels_m.geometry.simplify(5.0, preserve_topology=True)

# 2. Snap to a ~11 cm grid in EPSG:4326 so coordinates serialize short.
wgs = parcels_m.to_crs("EPSG:4326")
wgs["geometry"] = gpd.GeoSeries(
    shapely.set_precision(wgs.geometry.to_numpy(), 1e-6), crs="EPSG:4326"
)

# 3. Emit RFC 7946 GeoJSON: WGS84, lon/lat order, no feature ids.
payload = wgs.to_json(drop_id=True, to_wgs84=True)
print(f"{len(payload.encode()) / 1e6:.2f} MB")

With the payload measured, the dashboard itself is short. The pattern below is the canonical Streamlit map app: a cached loader, a widget, a Folium map, and a return value from the map used to drive a detail panel. The expanded version, including layer toggles and click-to-select, is in building a Streamlit map dashboard with Folium.

# app.py — streamlit run app.py
import folium
import geopandas as gpd
import streamlit as st
from streamlit_folium import st_folium

st.set_page_config(page_title="Parcel viewer", layout="wide")

@st.cache_data(ttl=3600, show_spinner="Loading parcels…")
def load_parcels(path: str, tolerance_m: float) -> gpd.GeoDataFrame:
    """Runs once per (path, tolerance) pair — not once per widget change."""
    gdf = gpd.read_file(path, columns=["parcel_id", "land_use"])
    utm = gdf.estimate_utm_crs()
    gdf = gdf.to_crs(utm)
    gdf["geometry"] = gdf.geometry.simplify(tolerance_m, preserve_topology=True)
    return gdf.to_crs("EPSG:4326")          # web maps expect WGS84

parcels = load_parcels("parcels.gpkg", tolerance_m=5.0)

land_use = st.sidebar.selectbox("Land use", ["all", *sorted(parcels["land_use"].unique())])
if land_use != "all":
    parcels = parcels[parcels["land_use"] == land_use]   # safe: cache_data returns a copy

m = folium.Map(location=[45.07, 7.69], zoom_start=12, tiles="CartoDB positron")
folium.GeoJson(
    parcels,
    name="parcels",
    tooltip=folium.GeoJsonTooltip(fields=["parcel_id", "land_use"]),
).add_to(m)

# returned_objects limits which map events come back — and therefore which rerun.
state = st_folium(m, height=600, use_container_width=True,
                  returned_objects=["last_object_clicked"])

if state and state.get("last_object_clicked"):
    st.sidebar.write(state["last_object_clicked"])

Payload & Rerun Processing Details

Streamlit's execution model is the thing that surprises people who arrive from Flask or Dash: there are no callbacks. Any widget interaction reruns the entire script from line one, in the same process, with widget values restored from session state. Reading a GeoPackage at module scope therefore reads it again on every checkbox toggle. Nothing about this is a bug — it is what makes the code linear — but it means caching is not an optimization here, it is a prerequisite.

Three tools bound the cost, and they are not interchangeable:

Streamlit rerun timeline with and without a data cache Two timelines for a single widget change. Without caching, the script repeats reading the file at 2.4 seconds, reprojecting at 0.8 seconds, simplifying at 0.5 seconds, building the Folium map at 0.3 seconds and rendering at 0.2 seconds, totalling about 4.2 seconds per interaction. With st.cache_data applied to the loader, the first three stages collapse into a single 0.02 second cache hit, leaving only the map build and render for a total of about 0.5 seconds. One widget change → one full script rerun No caching — every rerun repeats all five stages read_file() 2.4 s to_crs() 0.8 s simplify 0.5 s build map 0.3 s render 0.2 s ≈ 4.2 s of dead time on every click elapsed → @st.cache_data on the loader — load, reproject and simplify are served from cache cache hit — 0.02 s build map 0.3 s render 0.2 s ≈ 0.5 s — the map build is now the bottleneck Widget values survive the rerun in st.session_state; anything uncached is recomputed from line one.
Caching the loader does not make the app faster once — it makes every subsequent interaction cheap, which is the only speed users perceive.

The second half of the story is what you avoid sending at all. Once the dataset is large or shared between several dashboards, move it behind a tile endpoint and let the dashboard render a URL template instead of geometry. The PostGIS route is compact: ST_TileEnvelope builds the tile's bounds in Web Mercator, ST_AsMVTGeom clips and quantizes each geometry into tile coordinates, and ST_AsMVT aggregates the rows into one protobuf blob. Point it at a table indexed as described in spatial indexing in PostGIS with GiST and the per-tile query stays in single-digit milliseconds.

# tiles.py — uvicorn tiles:app --host 0.0.0.0 --port 8000 --workers 4
from fastapi import FastAPI, HTTPException, Response
from sqlalchemy import create_engine, text

app = FastAPI(title="Parcel vector tiles")
engine = create_engine("postgresql+psycopg://gis@db/city", pool_size=8, pool_pre_ping=True)

MVT_SQL = text("""
    WITH bounds AS (SELECT ST_TileEnvelope(:z, :x, :y) AS geom)     -- EPSG:3857
    SELECT ST_AsMVT(t, 'parcels', 4096, 'geom')
    FROM (
        SELECT p.parcel_id,
               p.land_use,
               ST_AsMVTGeom(ST_Transform(p.geom, 3857), bounds.geom, 4096, 64, true) AS geom
        FROM parcels p, bounds
        -- transform the tile box into the TABLE's CRS so the GiST index is used
        WHERE p.geom && ST_Transform(bounds.geom, 4326)
    ) AS t
""")

@app.get("/tiles/parcels/{z}/{x}/{y}.pbf")
def parcel_tile(z: int, x: int, y: int) -> Response:
    if not 0 <= z <= 16:
        raise HTTPException(status_code=404, detail="zoom outside published range")
    with engine.connect() as conn:
        (mvt,) = conn.execute(MVT_SQL, {"z": z, "x": x, "y": y}).one()
    if not mvt:
        return Response(status_code=204)          # empty tile is normal, not an error
    return Response(
        content=bytes(mvt),
        media_type="application/vnd.mapbox-vector-tile",
        headers={"Cache-Control": "public, max-age=300, s-maxage=86400"},
    )

Note the && bounding-box operator in the WHERE clause rather than ST_Intersects on the transformed geometry: the index lives on the stored column, so the tile envelope is transformed to the table's CRS instead of transforming a million rows into the tile's. The same reasoning appears in connecting GeoPandas to PostGIS with SQLAlchemy — push the predicate to the indexed side.

CRS Alignment & Projection Pipeline

Web delivery has exactly two coordinate systems, and mixing them up produces silent misplacement rather than an exception.

The practical rule for a dashboard is that projection is a pipeline with three fixed stages, and the metric stage never touches either web CRS:

import geopandas as gpd

sensors = gpd.read_file("sensors.gpkg")           # whatever CRS it arrived in
if sensors.crs is None:
    sensors = sensors.set_crs("EPSG:4326")        # declare the KNOWN source; do not guess

# 1. Analyse in a metric CRS — the local UTM zone, never 3857.
metric = sensors.to_crs(sensors.estimate_utm_crs())
metric["catchment"] = metric.geometry.buffer(500)          # 500 real metres

# 2. Hand off to the web layer in WGS84.
catchments = metric.set_geometry("catchment").to_crs("EPSG:4326")

# 3. The tile grid's 3857 is applied by the tiler, not by you.
assert catchments.crs.to_epsg() == 4326

Reprojecting inside a request handler is a smell. Do it once in the cached loader (a dashboard), once in the tile build (a static export), or once in the ingest job that populates PostGIS — never per interaction. Where a whole raster stack has to be aligned before it can be tiled, reproject_match from the raster-cube workflow does it in one call; see reprojecting raster cubes with reproject_match.

Production Export & Integration

Deployment is where the three shapes converge on one topology: a container behind a CDN, with the bulk data in object storage. What differs is which responses may be cached and by whom, and getting the headers wrong is the most common reason a correct app feels slow or fails in a browser but works in curl.

Content type, caching and CORS by response kind A three-row matrix. A vector tile is served as application/vnd.mapbox-vector-tile with a long immutable max-age when the path is versioned, and needs a plain GET allowed from the map origin. A PMTiles archive is served as application/octet-stream with a shared s-maxage, and requires the Range request header to be allowed and Accept-Ranges and Content-Range exposed. A GeoJSON API response uses application/geo+json with a short max-age plus stale-while-revalidate, and an explicit allowed-origins list. A closing warning states that a wrong content type on a pbf tile makes MapLibre fail silently with an empty map. Response kinds: content type, caching, CORS Response Content-Type Cache-Control CORS Vector tile .pbf application/vnd.mapbox- vector-tile public, max-age= 31536000, immutable GET from map origin PMTiles archive application/octet-stream public, s-maxage= 86400 allow Range header, expose Accept-Ranges GeoJSON endpoint application/geo+json public, max-age=300, stale-while-revalidate explicit origin list A wrong Content-Type on a .pbf makes MapLibre fail silently with an empty map.
Cache aggressively only where the URL is versioned; everything else gets a short freshness window plus revalidation.

The integration checklist, in the order things break:

# raster_tiles.py — a COG tile service in nine lines
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from titiler.core.factory import TilerFactory

app = FastAPI(title="Ortho tile service")
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://maps.example.org"],
    allow_methods=["GET"],
    allow_headers=["Range"],
    expose_headers=["Content-Range", "Accept-Ranges", "Content-Length", "ETag"],
    max_age=86400,
)

cog = TilerFactory(router_prefix="/cog")
app.include_router(cog.router, prefix="/cog", tags=["Cloud Optimized GeoTIFF"])
add_exception_handlers(app, DEFAULT_STATUS_CODES)
# GET /cog/tiles/WebMercatorQuad/12/2185/1497.png?url=s3://imagery/ortho.tif

Two operational settings finish the picture. Give the tile route a hard timeout — a request that has already spent five seconds rendering will not satisfy a browser that has moved on, and an unbounded handler turns one slow COG into a saturated worker pool. And log the tile coordinates plus the elapsed time per request; a heat map of slow {z}/{x}/{y} triples localizes the problem to a specific zoom band or a specific source file far faster than aggregate latency percentiles do.

The remaining decision — whether the dashboard container itself sits behind the CDN — is covered in deploying a Python map app behind a CDN. The short answer is that only its static assets benefit; the WebSocket connection carrying the app's state must bypass the cache entirely.

Windows / Platform Edge Cases & Debugging

Frequently Asked Questions

How large can a GeoJSON payload be before I need tiles? The practical ceiling is about 2 MB per viewport for a smooth experience, and roughly 10 MB before the browser becomes visibly unresponsive — parsing and geometry construction, not download, dominate past that point. Measure before deciding: serialize with GeoDataFrame.to_json() and check len(payload.encode()). Dropping unused attributes, simplifying in a metric CRS, and rounding to six decimal places routinely cuts a 12 MB export to under 2 MB, which is cheaper than building a tile pipeline. If it is still large after that, or if the data grows over time, move to vector tile pipelines with PMTiles.

Should I use st.cache_data or st.cache_resource for a GeoDataFrame? st.cache_data. It hashes the arguments, serializes the return value, and hands each caller a copy, so filtering or adding a column in one session cannot corrupt another's view of the data. st.cache_resource returns the same object to every session with no copy — correct for a SQLAlchemy engine or a loaded model, and dangerous for a frame you intend to mutate. The one case for st.cache_resource with spatial data is a read-only spatial index or a PostGIS connection pool shared across sessions.

Can a CDN sit in front of a Streamlit or Dash app? Partially, and it is worth doing for the static bundle only. Both frameworks maintain a live connection — a WebSocket in Streamlit, callback POSTs in Dash — that carries per-user state and must never be cached or coalesced. Configure the CDN to cache the framework's static asset paths aggressively and to pass everything else through with no-store. The real win is putting the tiles behind the CDN, since they are identical for every user and represent the overwhelming majority of bytes.

Do I still need a tile server if I already have PMTiles? No — that is the point of the format. A PMTiles archive is a single file read by the client with HTTP range requests, so object storage plus a CDN serves it with no Python process running. You need a server again only when tiles must be generated from live data, filtered per user, or access-controlled per layer; then a FastAPI service producing MVT (or serving MBTiles with Python) is the fallback.

Streamlit or Dash for a geospatial dashboard? Streamlit's top-to-bottom rerun model gets a working map in a page of code and is the right default for internal tools and analysis apps. Dash's explicit callback graph updates only the components you declare as outputs, which matters when a single page holds a map, several charts, and a table that must not all recompute together. The full comparison — including how each handles map click events and large payloads — is in Streamlit vs Dash for geospatial dashboards.

Why does my map render at the wrong location with correct-looking coordinates? Almost always axis order. GeoJSON is longitude-then-latitude by specification, while Folium's Python arguments (location, bounds, marker positions) are latitude-then-longitude, and PyProj transformers return authority axis order unless built with always_xy=True. A point that should be in Turin appearing off the coast of Africa is the classic signature of one reversed pair; verify with a single known coordinate before suspecting the projection.