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.

One hostname routed to two origins by path prefix A browser talks to a single CDN edge on maps.example.org, which routes by path prefix to two origins. The cacheable half is an object storage bucket holding hashed asset paths, a versioned tile prefix and a PMTiles archive, served immutable with a one-year max-age and carrying roughly ninety-eight percent of the bytes. The dynamic half is a Python container answering the api query routes plus the health and readiness probes, revalidated at the edge with a sixty second shared max-age and an ETag, carrying roughly two percent of the bytes. One hostname, two origins — the path prefix decides which Browser map client honours max-age CDN edge maps.example.org routes on the path prefix Object storage — cacheable half /assets/app.4f3a9c21.js /tiles/v2026-08-01/{z}/{x}/{y}.pbf /data/parcels.pmtiles immutable · max-age=31536000 · ~98% of bytes Python container — dynamic half /api/parcels?lon=…&lat=… /api/summary /healthz /readyz revalidated at the edge · ~2% of bytes
The split lives in the URL space: two origins behind one hostname, so no request has to be classified at runtime.

Prerequisites

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,
}]})
PMTiles reads with and without working range requests Two panels compare the same archive. On the left, range requests are allowed and the server answers 206 Partial Content: a first request for bytes zero to sixteen thousand three hundred eighty three returns the header and root directory at sixteen kilobytes, a second returns an eight kilobyte leaf directory, and a third returns one forty-two kilobyte tile, for three round trips and about sixty-six kilobytes. On the right, the range header is stripped and the server answers 200 OK with the whole one-point-eight gigabyte archive, because Accept-Ranges is missing, Content-Range is not exposed to the script, or the CDN caches whole objects and drops the header; the map stalls or the tab runs out of memory. What the PMTiles client actually asks for Range allowed → 206 Partial Content 1 · Range: bytes=0-16383 header + root directory — 16 KB 2 · Range: bytes=… leaf dir leaf directory — 8 KB 3 · Range: bytes=… one tile the tile itself — 42 KB 3 round trips · ≈ 66 KB transferred Range stripped → 200 OK GET /data/parcels.pmtiles the whole archive — 1.8 GB per client · the bucket never sends Accept-Ranges · Content-Range is not in the expose list · the edge caches whole objects, dropping Range the map stalls, or the tab runs out of memory Fix: allow Range on the bucket, expose Accept-Ranges and Content-Range, and keep Range in the edge cache key.
Sixty-odd kilobytes or the entire archive — the difference is three header settings, none of which produce an error when missing.

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
A deploy that adds paths instead of replacing them Five ordered deploy steps with the cache state under each. Build hashes every asset and opens a new tile prefix while the edge cache is untouched. Upload writes only new keys, so the hit ratio is unchanged. Verify issues HEAD requests at the origin to check types and ranges, still with zero purges. Swap overwrites index.html and manifest.json, the only objects with a sixty second time to live. Purge invalidates three paths rather than a wildcard. Two closing bands contrast purging slash star, which sends every edge cold at once and pushes the whole map's byte volume at the origin, with purging three entry points, which leaves the hit ratio where it was. A deploy that adds paths instead of replacing them 1 build hash every asset open a tile prefix 2 upload new keys only headers per suffix 3 verify HEAD at the origin types and ranges 4 swap index + manifest 60 s objects only 5 purge three paths never a wildcard edge cache untouched hit ratio unchanged zero purges so far entry points now point at v2 3 objects invalidated Purge /* — every edge goes cold at once, and the origin absorbs the whole map's byte volume for the length of one cache fill, on the deploy you were least free. Purge three entry points — the hashed assets and the versioned tile prefix were never cached under those names, so there is nothing at the edge to throw away.
New content lands on new paths, so a deploy invalidates three objects rather than the entire cache.

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

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.