Versioned Tile URL Rotation Without Cache Purges

Embed a schema hash in the tile path prefix, publish each rebuild to that fresh path, flip the style’s pointer to it, and never purge the CDN. Because the old prefix is still valid and the new prefix is what the style now names, a deploy is atomic and a global cache purge — the slow, stampede-inducing operation you are trying to avoid — is never required.

When to Use This

Use prefix rotation whenever your tiles are cached immutably and rebuilt on a schedule: a nightly OpenStreetMap extract, a weekly parcel refresh, a CI job that regenerates tiles on data change. The moment you commit to public, max-age=31536000, immutable on tile paths — the header described in Cache-Control headers for immutable vector tiles — you also commit to never overwriting a path in place, because an immutable object cannot be updated by re-upload; caches will ignore the new bytes. Rotation is the mechanism that makes immutable caching and frequent rebuilds coexist.

If your tiles are served with a short TTL and no immutable directive, you do not need this — a normal purge or natural expiry suffices. Rotation earns its keep precisely when the cache lifetime is long.

Specification Detail

Computing a stable schema hash. The prefix must be deterministic: identical inputs must produce the same hash so an unchanged rebuild does not needlessly rotate, and any schema or data change must produce a new one. Hash the tileset metadata — the PMTiles header plus its tile-JSON (layer names, fields, min/max zoom, bounds) — not the wall-clock time.

Input to the hash Why it belongs
Layer names + field lists A renamed layer must force a new prefix so it cannot mix with an old style
min/max zoom, bounds Coverage changes should invalidate old tiles
Content digest of the archive Any byte change in the tiles rotates the prefix
Build timestamp Excluded — including it defeats the “unchanged rebuild = same prefix” property

Path layout. The prefix sits at the front of the path so it is part of the cache key and trivially matched by CDN and lifecycle rules:

text
/v{hash}/{z}/{x}/{y}.pbf         # dynamic-style tile paths
/v{hash}/roads.pmtiles           # single-archive layout

A short digest (the first 8 hex characters of a SHA-1 over the metadata) is enough — collision probability is negligible at the handful of builds a prefix ever needs to disambiguate.

Production Command

A complete rebuild-and-rotate step, suitable for a CI job:

bash
#!/usr/bin/env bash
set -euo pipefail

BUCKET="s3://tiles.example.com"
STYLE_KEY="style.json"

# 1) Derive a stable prefix from tileset metadata (not the clock)
HASH=$(pmtiles show roads.pmtiles --header-json \
  | python3 -c "import sys,hashlib;print(hashlib.sha1(sys.stdin.buffer.read()).hexdigest()[:8])")
PREFIX="v${HASH}"
echo "Publishing under /${PREFIX}/"

# 2) Upload to the prefixed path with an immutable header
aws s3 cp roads.pmtiles "${BUCKET}/${PREFIX}/roads.pmtiles" \
  --content-type application/octet-stream \
  --cache-control "public, max-age=31536000, immutable"

# 3) Pre-warm the busiest low-zoom tiles so the flip does not cold-start every POP
for z in 0 1 2 3 4; do
  curl -s -o /dev/null "https://tiles.example.com/${PREFIX}/roads.pmtiles" \
    -H "Range: bytes=0-16383"   # PMTiles header + root directory
done

# 4) Template the new prefix into the style's sources.url and publish it
python3 - "$PREFIX" <<'PY'
import json, sys
prefix = sys.argv[1]
style = json.load(open("style.template.json"))
style["sources"]["roads"]["url"] = f"pmtiles://https://tiles.example.com/{prefix}/roads.pmtiles"
json.dump(style, open("style.json", "w"), separators=(",", ":"))
PY

# 5) Flip the pointer — the ONLY mutable write, on a short TTL
aws s3 cp style.json "${BUCKET}/${STYLE_KEY}" \
  --content-type application/json \
  --cache-control "public, max-age=300, stale-while-revalidate=60"

echo "Live on /${PREFIX}/ — no purge issued"

Step 5 is the atomic flip: the style is the single mutable object, and because it carries a 5-minute TTL, clients pick up the new prefix within minutes while old tiles keep serving from cache until then.

Interaction Effects

Pairs with immutable Cache-Control. Rotation and the immutable header from the sibling guide are two halves of one design. Immutable caching gives you the hit ratio and the zero-revalidation wins; rotation gives you a way to ship new data despite those bytes being frozen forever. Neither works alone — immutable without rotation means you can never update, and rotation without immutable means you gave up the cache benefit you rotated to protect.

Dual-deploy window. Between the tile upload (step 2) and the style flip (step 5), both the old and new prefixes are live and valid. That overlap is a feature: in-flight clients on the old style keep hitting the old prefix with no errors, and there is no instant during which a client can request a tile that does not exist. You can widen the window deliberately — hold both prefixes for a release cycle — to support gradual rollout or fast rollback (flip the style back to the old prefix, still warm in cache).

Ties to the style version-pair gate. The correctness of the flip depends on the tiles and the style being a matched pair: a tile prefix names a schema, and the style that consumes it must reference the same schema’s layer names and fields. Treating the (prefix, style) pair as the unit of deploy — never updating one without the other — is what guarantees a new style never lands against old-schema tiles.

Performance Impact

  • No purge latency. A global CDN purge propagates over seconds to minutes and cold-starts every POP; rotation replaces it with a single small-object write (the style) that costs nothing at the edge. Deploy time drops to the time to upload one JSON file.
  • Old prefixes age out naturally. Because nothing points at the old prefix after the flip, its edge entries simply expire on their own TTL and its origin objects sit idle until a lifecycle rule removes them — no active invalidation, no stampede.
  • Bounded origin egress. Misses occur only for the new prefix on first access; pre-warming the low-zoom tiles collapses even that into a handful of origin fetches. A busy basemap can rotate nightly and still run origin egress in the low single-digit percent of total traffic.

Common Mistakes

1. Reusing a prefix. Publishing a changed build under a prefix that already served different bytes is the cardinal error — immutable caches will keep serving the old bytes, and any client that fetches fresh gets a schema that may not match. The symptom is the “mixed old/new schema” failure: some layers render, others throw source layer "..." does not exist. Fix: derive the prefix from a content digest so different bytes can never collide onto the same path.

2. Forgetting to update the style’s sources.url. If you upload to a new prefix but never flip the pointer, clients keep loading the old prefix indefinitely — the deploy silently does nothing.

bash
# Confirm the live style actually names the new prefix:
curl -s "https://tiles.example.com/style.json" \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['sources']['roads']['url'])"
# pmtiles://https://tiles.example.com/va1b2c3d4/roads.pmtiles

If the printed prefix is not the one you just published, step 5 did not run.

3. Unbounded prefix accumulation. Every rebuild leaves an old prefix on the origin; a nightly job produces 365 dead prefixes a year, and object-storage bills grow accordingly. Add a lifecycle rule to delete prefixes past a retention window — long enough to cover rollback and the longest client cache lifetime, typically 7–30 days.

bash
aws s3api put-bucket-lifecycle-configuration \
  --bucket tiles.example.com \
  --lifecycle-configuration '{"Rules":[{"ID":"expire-old-tile-prefixes",
    "Filter":{"Prefix":"v"},"Status":"Enabled",
    "Expiration":{"Days":30}}]}'

Parent: CDN Cache Headers & Versioned Tile URLs