Deploying a Python Map App Behind a CDN
A map app put behind a content delivery network with one cache rule for everything either serves stale query results to the wrong user or caches nothing at all and hands the Python process every byte the map draws. This guide is for anyone taking a working map to production and deciding what the container is still allowed to serve; it sits under Geospatial Dashboards & App Deployment in Web Mapping & Interactive Visualization.
Why This Approach / What Goes Wrong
A deployed map is two workloads wearing one hostname. The first is bytes that are byte-identical for every visitor and change only when you rebuild: the JavaScript bundle, the style document, glyphs and sprites, vector tiles, a PMTiles archive. The second is answers that depend on the request: a bounding-box query, a filtered selection, an authorization check. Their cache profiles are opposites — the first wants a year, the second wants zero seconds — and any single policy applied across both is wrong in one direction.
Applying the strict policy everywhere is the common outcome, because it is the safe-looking one. The result is a container that serves the whole map from Python: every pan re-requests tiles the edge could have answered in a millisecond, worker slots are occupied by static file reads, and the autoscaler grows the fleet in proportion to traffic that should have been flat. Applying the loose policy everywhere is rarer and worse — one user's filtered query gets cached at an edge and returned to the next visitor.
The fix is not a cleverer header. It is deciding, before deployment, which URL prefixes the Python process is allowed to answer at all. Everything static moves to object storage under paths that change when the content changes, so it can carry immutable honestly. The app keeps only the routes that must execute Python, and those are marked so the edge may revalidate them but never store them for long. The CDN then routes by path prefix to two different origins, and the split becomes a property of the URL space rather than a runtime decision.
Prerequisites
fastapi>=0.110— the dynamic half;Responsewith explicit headers is the whole caching API you needuvicorn[standard]>=0.29— ASGI server with--workers,--timeout-keep-aliveand--limit-concurrencygeopandas>=1.0— loads and projects the query dataset once at import, with pyogrio as the default engineboto3>=1.34— uploads objects with per-type headers and issues the invalidationhttpx>=0.27— the verification client, because it sends a realOriginand honoursRange
pip install "fastapi>=0.110" "uvicorn[standard]>=0.29" "geopandas>=1.0" \
"boto3>=1.34" "httpx>=0.27"
Step-by-Step Implementation
1. Strip the app down to the routes that must run Python.
Mount no static directory and serve no tiles from the app. What remains is a query surface plus two probes. Note the projection discipline: the dataset is reprojected to a local UTM zone once at import, the radius filter is evaluated in real metres there — never in EPSG:3857, whose scale error grows as 1/cos(latitude) — and only the result is converted back to WGS84 longitude-then-latitude for the wire.
# app.py — the dynamic half: queries and probes, nothing cacheable
import geopandas as gpd
from fastapi import FastAPI, Query, Response
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Parcel query API")
# An explicit origin list makes Starlette emit `Vary: Origin` on every response,
# which is what stops an edge from reusing one origin's answer for another.
app.add_middleware(CORSMiddleware, allow_origins=["https://maps.example.org"],
allow_methods=["GET"], max_age=86400)
PARCELS = gpd.read_file("parcels.gpkg", columns=["parcel_id", "land_use"])
UTM = PARCELS.estimate_utm_crs() # local zone chosen from the extent
PARCELS_M = PARCELS.to_crs(UTM) # projected once, never per request
@app.get("/api/parcels")
def parcels_near(lon: float, lat: float,
radius_m: float = Query(500, gt=0, le=5000)) -> Response:
point = gpd.GeoSeries.from_xy([lon], [lat], crs="EPSG:4326").to_crs(UTM)
catchment = point.buffer(radius_m).iloc[0] # metres, not degrees
hits = PARCELS_M[PARCELS_M.intersects(catchment)].to_crs("EPSG:4326")
return Response(
content=hits.to_json(drop_id=True),
media_type="application/geo+json",
headers={"Cache-Control":
"public, max-age=0, s-maxage=60, stale-while-revalidate=30"},
)
max-age=0, s-maxage=60 is the asymmetry that makes this safe: the browser stores nothing, while the shared cache may hold the answer for a minute and serve it stale for thirty seconds more while it refreshes. A burst of identical viewport queries collapses into one origin request without any user ever seeing another user's cached response.
2. Give every cacheable file a name that changes when its bytes change.
immutable is a promise you can only keep if the URL is content-addressed. Hash each asset, build a manifest, and put the tile build under a dated prefix. Nothing here overwrites anything.
# build_manifest.py — content-addressed names for everything the browser caches
import hashlib, json, shutil
from pathlib import Path
SRC, OUT = Path("src"), Path("dist")
VERSION = "v2026-08-01" # one prefix per tile build
HASHED = (".js", ".css", ".json")
def digest(path: Path, length: int = 8) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()[:length]
(OUT / "assets").mkdir(parents=True, exist_ok=True)
manifest = {}
for asset in sorted(p for p in SRC.iterdir() if p.suffix in HASHED):
hashed_name = f"{asset.stem}.{digest(asset)}{asset.suffix}"
shutil.copy2(asset, OUT / "assets" / hashed_name)
manifest[asset.name] = f"/assets/{hashed_name}"
manifest["tiles"] = f"/tiles/{VERSION}///.pbf"
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2))
print(json.dumps(manifest, indent=2))
# {
# "app.js": "/assets/app.4f3a9c21.js",
# "style.json": "/assets/style.c05a2f18.json",
# "tiles": "/tiles/v2026-08-01/{z}/{x}/{y}.pbf"
# }
Exactly two objects keep stable names — index.html and manifest.json — and they are the only things a deploy overwrites. Everything the map loads is reached through them.
3. Set the content type and the cache policy at upload time.
Object storage defaults an unknown extension to binary/octet-stream, which is how a correct .pbf produces a blank map. Encode the policy as a table and apply it on every put, with the entry points exempted from immutable.
# publish.py — one content-type and Cache-Control policy, applied per object
import mimetypes
from pathlib import Path
import boto3
s3 = boto3.client("s3")
BUCKET = "maps-example-org"
IMMUTABLE = "public, max-age=31536000, immutable"
ENTRY = "public, max-age=0, s-maxage=60, must-revalidate"
ENTRY_POINTS = {"index.html", "manifest.json"}
# suffix -> (Content-Type, Cache-Control, Content-Encoding)
POLICY = {
".js": ("application/javascript", IMMUTABLE, None),
".css": ("text/css", IMMUTABLE, None),
".json": ("application/json", IMMUTABLE, None),
".geojson": ("application/geo+json", IMMUTABLE, None),
".pbf": ("application/vnd.mapbox-vector-tile", IMMUTABLE, "gzip"),
".pmtiles": ("application/octet-stream",
"public, max-age=3600, s-maxage=604800", None),
".html": ("text/html; charset=utf-8", ENTRY, None),
}
def publish(local: Path, key: str) -> None:
fallback = (mimetypes.guess_type(local.name)[0] or "application/octet-stream",
"public, max-age=300", None)
content_type, cache_control, encoding = POLICY.get(local.suffix, fallback)
if local.name in ENTRY_POINTS:
cache_control = ENTRY # the only mutable objects
extra = {"ContentType": content_type, "CacheControl": cache_control}
if encoding:
extra["ContentEncoding"] = encoding # tippecanoe writes gzipped MVT
s3.upload_file(str(local), BUCKET, key, ExtraArgs=extra)
The .pbf row carries two easily missed details. Vector tiles produced by a tile build are already gzip-compressed, so ContentEncoding: gzip must be declared or the client hands MapLibre compressed bytes it cannot parse — the same failure that appears when serving MBTiles with Python without declaring the encoding. And the CDN must not be allowed to compress that path a second time.
4. Allow range requests, and expose the headers the client reads.
A PMTiles archive is one large object that the browser reads in pieces. If the bucket refuses Range from a cross-origin script, or the CORS policy does not expose Accept-Ranges and Content-Range, the client cannot do partial reads at all.
s3.put_bucket_cors(Bucket=BUCKET, CORSConfiguration={"CORSRules": [{
"AllowedOrigins": ["https://maps.example.org"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "If-None-Match"],
"ExposeHeaders": ["Accept-Ranges", "Content-Range", "Content-Length", "ETag"],
"MaxAgeSeconds": 86400,
}]})
5. Separate liveness from readiness, then size the container from one worker.
An orchestrator that probes a route touching the dataset will kill the container during a slow cold start. Liveness must answer while every dependency is down; readiness is the one allowed to fail.
@app.get("/healthz") # liveness — no I/O, answers immediately
def healthz() -> Response:
return Response(status_code=204, headers={"Cache-Control": "no-store"})
@app.get("/readyz") # readiness — proves the data is actually loaded
def readyz() -> Response:
code = 204 if not PARCELS_M.empty else 503
return Response(status_code=code, headers={"Cache-Control": "no-store"})
uvicorn app:app --host 0.0.0.0 --port 8000 --workers 3 \
--timeout-keep-alive 75 --limit-concurrency 128
Workers are processes, not threads: each one holds its own copy of PARCELS_M, so the count follows from memory, not cores. A 320 MB projected frame plus roughly 120 MB of interpreter and GEOS gives about 440 MB per worker, which is three workers in a 2 GB container with headroom to spare — not the eight that a CPU count suggests. Two more numbers matter. --timeout-keep-alive must exceed the CDN's origin keep-alive (commonly 60 seconds) so the edge never reuses a socket the app has just closed, which surfaces as sporadic 502s under low traffic. And --limit-concurrency returns 503 immediately past the ceiling instead of queueing requests the browser has already abandoned.
6. Deploy by adding paths, and purge only the entry points.
Because every asset and tile prefix from step 2 is new, publishing them invalidates nothing. The deploy becomes: upload, verify at the origin, overwrite the two entry points, then purge those two plus the style document the manifest names.
# deploy.py — the invalidation is three paths, not a wildcard
import time
import boto3
cf = boto3.client("cloudfront")
PATHS = ["/index.html", "/manifest.json", "/assets/style.c05a2f18.json"]
resp = cf.create_invalidation(
DistributionId="E2QWERTY123456",
InvalidationBatch={
"Paths": {"Quantity": len(PATHS), "Items": PATHS},
"CallerReference": f"deploy-{int(time.time())}",
},
)
print(resp["Invalidation"]["Status"]) # InProgress
Keep the previous tile prefix in the bucket for at least as long as the old manifest.json could still be held by a browser — an hour is plenty with a 60 second entry-point TTL — then delete it in a scheduled job.
Verification
Check each request class against the live hostname, with an Origin header, because a browser sends one and curl does not.
import httpx
BASE = "https://maps.example.org"
ORIGIN = {"Origin": BASE}
# 1. A hashed asset may be stored forever by both caches
r = httpx.head(f"{BASE}/assets/app.4f3a9c21.js", headers=ORIGIN)
assert "immutable" in r.headers["cache-control"], r.headers["cache-control"]
# 2. A vector tile: correct type, and its gzip encoding declared
t = httpx.get(f"{BASE}/tiles/v2026-08-01/12/2185/1497.pbf", headers=ORIGIN)
assert t.headers["content-type"] == "application/vnd.mapbox-vector-tile"
# 3. The exact first read a PMTiles client performs
p = httpx.get(f"{BASE}/data/parcels.pmtiles",
headers={**ORIGIN, "Range": "bytes=0-16383"})
assert p.status_code == 206 and len(p.content) == 16384
assert p.headers["content-range"].startswith("bytes 0-16383/")
exposed = p.headers.get("access-control-expose-headers", "").lower()
assert "accept-ranges" in exposed and "content-range" in exposed
# 4. The dynamic half: shared cache only, and keyed on Origin
q = httpx.get(f"{BASE}/api/parcels", params={"lon": 7.69, "lat": 45.07},
headers=ORIGIN)
assert "s-maxage=60" in q.headers["cache-control"]
assert "Origin" in q.headers.get("vary", "")
print(r.headers.get("x-cache"), t.headers.get("age"), q.status_code)
# Hit from cloudfront 41213 200
An age in the tens of thousands on the tile is the number that matters: that object has been at the edge for half a day and the container has never seen it.
Edge Cases & Debugging
- The range request returns
200with the whole body. The edge is configured for whole-object caching and dropsRange, or the bucket is fronted by a proxy that buffers. Confirm the origin itself is fine withcurl -r 0-16383 -o /dev/null -w '%{http_code}\n' <origin-url>— a206there and a200at the edge localizes it to the CDN. - Tiles download but the map stays empty. The
.pbfobjects are gzipped withoutContentEncoding: gzip, or the CDN compressed them a second time. Checkcurl -sI <tile> | grep -i content-encodingreturns exactly onegzip. - A second origin gets the first origin's CORS answer. The response was cached without
Vary: Origin. Keep the explicit origin list on the middleware, and confirmVarysurvives to the edge — some configurations strip it to raise hit ratios. index.htmlis pinned in browsers for a year. An earlier deploy sentmax-age=31536000on the entry point, and no purge can reach a private cache. The only recovery is a new URL; ship entry points withmax-age=0, must-revalidatefrom the first deploy.- The container restarts in a loop after a cold start. The liveness probe points at a route that loads the dataset. Point it at
/healthz, keep readiness on/readyz, and give the orchestrator a startup grace period longer than the firstread_file. - Hit ratio collapses after each release. The pipeline still ends with a wildcard purge. Delete it — with hashed assets and a versioned tile prefix, only the entry points need invalidating.
Frequently Asked Questions
Do the tiles need their own hostname?
No, and a second hostname costs you something. Routing /tiles/, /assets/ and /data/ to the bucket on the same hostname the page is served from makes every fetch same-origin, which removes CORS preflights from the critical path entirely. Configure the bucket's CORS rules anyway — they cost nothing and cover the case where the archive is later embedded by another site — but do not split hostnames purely to separate origins.
What if the tile path cannot be versioned?
Fall back to s-maxage plus revalidation: a moderate shared TTL, an ETag on every object, and stale-while-revalidate so a refresh never blocks a user. Then, if your CDN supports surrogate keys or cache tags, tag every tile from one build with the build id and purge by tag — one API call retires a whole layer without touching the rest of the cache. This is the mechanism a live tile service needs when it renders from a database rather than from a build, as in serving raster tiles from FastAPI with TiTiler.
Why is the first minute after a deploy slow?
Two cold things overlap: the edge has no copy of the new asset paths, and the new container has not yet loaded its data. Stagger them — roll the container first and wait for /readyz before swapping the entry points — and pre-warm the low zoom levels by requesting a few dozen tiles from the new prefix as the last step of the deploy job. Enabling an origin shield also helps, because it collapses the fan-in from every edge into a single origin fetch per object.
Does a CDN help at all if I only ship a PMTiles archive?
Yes, provided it caches partial responses. With range caching enabled, the archive's header and directories are served from the edge on the first request in a region and stay hot; without it, every pan is a full round trip to object storage and the archive might as well be on a single server. Verify with the 206 assertion above before assuming it works — the archive built in generating PMTiles from GeoParquet behaves very differently depending on that one setting.