Serving MVT Tiles from Cloud Storage with a CDN
Upload every .pbf with Content-Type: application/x-protobuf, Content-Encoding: gzip, and a Cache-Control that carries both no-transform and a per-zoom max-age, then put an external HTTPS load balancer with Cloud CDN in front of the bucket. no-transform is the header nobody sets and everybody needs: without it Cloud Storage will decompressively transcode a tile for any client that did not send Accept-Encoding: gzip, strip the Content-Encoding header on the way out, and hand your map client compressed bytes it has no idea it must inflate — a blank map, HTTP 200, no error anywhere.
Context
The vector tile pipeline with Cloud Run and Pub/Sub ends with an unpacked z/x/y.pbf pyramid sitting in a bucket, written by the tiler described in generating MVT tiles with tippecanoe in Cloud Run. Everything from that point on is a serving problem, and it is an unusually unforgiving one, because a vector tile has no way of telling a client that it arrived wrong. A raster tile that fails to decode is a broken image; an MVT tile that fails to decode is an empty layer, drawn silently over an otherwise working map.
A pre-rendered pyramid is worth serving statically precisely because there is no server to get right. A national parcel layer rendered to zoom 14 is roughly 1.4 million tiles and a few gigabytes, and each tile is an immutable byte string — a protobuf carrying geometry on a 4,096-unit integer grid, drawn by the client into the 256 × 256 pixel tile the web-mercator convention assumes. Object storage plus a CDN serves that at any request rate for the price of storage and egress, with no cold start, no instance, and no timeout, which is the whole reason the pipeline pre-renders instead of tiling on demand.
What the serving tier has to get right is metadata, not compute. Five settings decide whether the map works, whether the CDN caches, and whether a re-render is visible today or next month. The first three are per-object and are set once at upload; the last two are bucket- and load-balancer-level.
Prerequisites
- A tiles bucket with uniform bucket-level access, public read via
allUsers:objectViewer, and no per-object ACLs. Keep it separate from the source bucket so the least-privilege split described in IAM security boundaries for cloud GIS survives. - An external Application Load Balancer with a backend bucket, Cloud CDN enabled,
cache_mode = CACHE_ALL_STATICso originCache-Controlheaders are honoured.FORCE_CACHE_ALLoverrides them, which is convenient and removes your per-zoom control entirely. - A custom domain and certificate, because the browser’s CORS decision and the cache key both depend on origin.
- Tiles written by
tile-join --no-tile-compression -e, so the uploader owns compression and can set the encoding header in the same call — a mismatch between who gzips and who declares it is the single most common cause of a blank map. - Environment values used below:
TILES_BUCKET=geo-vt-tiles LAYER=parcels VERSION=v7 MAP_ORIGINS=https://maps.example.com,https://staging.maps.example.com
Cache lifetimes are a function of zoom
A tile pyramid has wildly different access and change patterns at its top and its bottom, and giving it one max-age is a decision to be wrong at one end.
Zooms 0 to 6 are a few thousand tiles that every single session requests and that essentially never change: their content is the shape of the country, generalised past the point where a parcel edit is visible. Thirty days of cache costs nothing and removes the majority of origin requests. Zooms 12 to 14 hold the actual data, are requested sparsely, and are exactly where a re-render must become visible; an hour of max-age with stale-while-revalidate=86400 gives a fast response from cache while the CDN refreshes behind it.
The exception swallows the rule: if tiles are published under a version-stamped prefix, every tile at every zoom becomes immutable and can be cached for a year, because a re-render writes to a different path. That is the strategy the purge section below argues for.
Implementation
The uploader sets all three per-object headers and derives max-age from the zoom level in the object’s own key.
# publish_pyramid.py — upload a z/x/y.pbf pyramid with correct serving metadata.
import gzip
import io
import os
from google.cloud import storage
client = storage.Client()
BUCKET = client.bucket(os.environ["TILES_BUCKET"])
LAYER = os.environ["LAYER"]
VERSION = os.environ["VERSION"] # e.g. "v7" — part of the object path
# Cache lifetime by zoom band. The pyramid is not one cache policy: the low
# zooms are stable and universally requested, the high zooms carry the edits.
TTL_BY_ZOOM = [
(6, "public, max-age=2592000, no-transform"), # 30 days
(11, "public, max-age=604800, no-transform"), # 7 days
(24, "public, max-age=3600, stale-while-revalidate=86400, "
"no-transform"), # 1 hour
]
# A version-stamped prefix makes every tile immutable, so nothing under it
# ever needs invalidating and the whole pyramid can be cached for a year.
IMMUTABLE = "public, max-age=31536000, immutable, no-transform"
def cache_control(zoom: int, versioned: bool) -> str:
if versioned:
return IMMUTABLE
for ceiling, value in TTL_BY_ZOOM:
if zoom <= ceiling:
return value
raise ValueError(zoom)
def publish(tile_dir: str, versioned: bool = True) -> int:
count = 0
for root, _dirs, files in os.walk(tile_dir):
for name in files:
if not name.endswith(".pbf"):
continue
local = os.path.join(root, name)
rel = os.path.relpath(local, tile_dir) # "14/8402/5379.pbf"
zoom = int(rel.split("/", 1)[0])
prefix = f"{VERSION}/{LAYER}" if versioned else LAYER
blob = BUCKET.blob(f"{prefix}/{rel}")
# Compress here, and declare it here, in the same call. Splitting
# those two across tippecanoe and the uploader is how a pyramid
# ends up half-declared after a retried run.
with open(local, "rb") as fh:
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz:
gz.write(fh.read())
blob.content_type = "application/x-protobuf"
blob.content_encoding = "gzip"
# no-transform is the load-bearing token: without it Cloud Storage
# may decompress the object for a client that omitted
# Accept-Encoding, and strip Content-Encoding while doing so.
blob.cache_control = cache_control(zoom, versioned)
blob.upload_from_string(buf.getvalue(),
content_type="application/x-protobuf")
count += 1
return count
The bucket-level and CDN-level settings are one-time and belong in your infrastructure definition rather than in the uploader:
# CORS: MapLibre fetches tiles with fetch(), so the browser enforces
# same-origin and drops the response without these headers.
cat > cors.json <<'JSON'
[{
"origin": ["https://maps.example.com", "https://staging.maps.example.com"],
"method": ["GET", "HEAD"],
"responseHeader": ["Content-Type", "Content-Encoding", "Cache-Control"],
"maxAgeSeconds": 3600
}]
JSON
gcloud storage buckets update "gs://${TILES_BUCKET}" --cors-file=cors.json
# Backend bucket + Cloud CDN. CACHE_ALL_STATIC honours the per-object
# Cache-Control set above; FORCE_CACHE_ALL would override it wholesale.
gcloud compute backend-buckets create vt-tiles-backend \
--gcs-bucket-name="${TILES_BUCKET}" \
--enable-cdn --cache-mode=CACHE_ALL_STATIC \
--default-ttl=3600 --max-ttl=31536000 --client-ttl=3600 \
--negative-caching
Negative caching matters more than it sounds for a sparse pyramid. tippecanoe writes only tiles that contain features, so a map panning over open sea requests thousands of tiles that do not exist; without negative caching every one of those 404s reaches the bucket.
Purging a region after a re-render
Re-rendering is rarely global. A municipality republishes its parcels and 40 km of the country changes — which, at latitude 52, is about 7 tiles across at zoom 12, 14 at zoom 13 and 27 at zoom 14: a little under a thousand tiles in total, scattered across three directories, with the count roughly quadrupling per zoom level.
Cache invalidation cannot express that. It matches path prefixes, so the only workable invalidation is /parcels/14/*, which purges every zoom-14 tile in the country to fix a thousand of them, or several hundred individual paths against a rate-limited control-plane API that takes minutes to propagate. Both are worse than the alternative: render into /v8/parcels/, publish the new style URL, and let /v7/ expire on its own schedule. The swap is atomic per session, the tiles are genuinely immutable so the year-long TTL is honest, and rollback is changing one string back. The cost is holding two copies of the pyramid until a lifecycle rule deletes the old prefix — a few gigabytes of standard-class storage, against an invalidation that empties the CDN of a whole zoom level and sends every subsequent request to the origin.
Keep invalidation for what it is good at: a single bad tile, a hotfix, a mistake you need gone in minutes rather than at the next scheduled render.
Verification
Check the two headers a map client depends on, both with and without Accept-Encoding, and confirm the CDN is actually caching.
TILE="https://maps.example.com/v7/parcels/12/2098/1345.pbf"
# 1) Normal browser request: gzip should come back gzip.
curl -sI -H 'Accept-Encoding: gzip' "$TILE" \
| grep -iE 'content-type|content-encoding|cache-control|age|via'
# 2) A client that did NOT ask for gzip. With no-transform the stored bytes
# are served untouched; without it, GCS transcodes and drops the header.
curl -sI "$TILE" | grep -iE 'content-encoding|x-goog-stored'
# 3) CORS preflight from the map's origin.
curl -sI -H 'Origin: https://maps.example.com' "$TILE" \
| grep -i 'access-control-allow-origin'
# 4) The tile really is a vector tile, not an error page.
curl -s -H 'Accept-Encoding: gzip' "$TILE" | gunzip | head -c 16 | xxd | head -1
Expected output:
content-type: application/x-protobuf
content-encoding: gzip
cache-control: public, max-age=31536000, immutable, no-transform
age: 2417
via: 1.1 google
content-encoding: gzip
x-goog-stored-content-encoding: gzip
access-control-allow-origin: https://maps.example.com
00000000: 1a89 0f78 0a06 7061 7263 656c 1580 2002 ...x..parcel.. .
The age header is the proof the CDN served this from cache rather than the bucket; a request that always returns age: 0 is not being cached, and the usual cause is a Cache-Control the cache mode is overriding. The second content-encoding: gzip — on the request that never asked for it — is the no-transform check. And the hex dump beginning with the protobuf field tag followed by the layer name is the only test that confirms the bytes are a real MVT rather than an XML error document with the right headers.
Gotchas and Edge Cases
- Double compression is invisible until it is not. If
tippecanoegzips the tile and the uploader gzips it again, the object is valid gzip,Content-Encoding: gzipis technically true, and the browser inflates exactly once — leaving the parser holding compressed bytes. This is why--no-tile-compressionbelongs on bothtippecanoeandtile-join, and why the uploader owns compression alone. FORCE_CACHE_ALLsilently discards the per-zoom policy. It is the fastest way to get a cache hit ratio in a demo and it makes the entireTTL_BY_ZOOMtable dead code, including on tiles you meant to keep short-lived. If you use it, use it only under a versioned, immutable prefix where a single TTL is the correct answer.- CORS is bucket-level, but the response comes from the CDN. A CORS change takes effect at the origin immediately and at the edge only when the cached object is re-fetched. A tile cached for 30 days before you fixed CORS keeps returning the old, header-less response until it expires — one of the few situations where invalidation genuinely is the right tool.
- The style URL is part of the cache key, and so is the query string. Appending
?v=8to the tile URL template is a popular cache-busting trick and it does work, but it multiplies your cache entries and leaves the old ones resident. A path-based version prefix costs the same storage and keeps the cache clean. mtime=0in the gzip header is not cosmetic. The default gzip header embeds the current time, so re-uploading an identical tile produces different bytes and a different ETag, defeating conditional requests and any content-hash comparison you might use to skip unchanged tiles during a re-render.
Frequently Asked Questions
Why does my map render blank even though the tiles download fine?
Almost certainly a missing or stripped Content-Encoding: gzip. The tile is gzip-compressed protobuf; without the header the browser passes the compressed bytes straight to the vector-tile parser, which finds no valid layer and draws nothing — HTTP 200, correct length, no error. Inspect the response headers rather than the status, and add no-transform to Cache-Control so Cloud Storage cannot decompress the object and drop the header while doing so.
Should every zoom level have the same cache TTL?
No. Zooms 0–6 are a few thousand tiles requested by every session that essentially never change, so weeks of cache is free performance. Zooms 12–14 are where edits land and where staleness is visible, so an hour of max-age with stale-while-revalidate keeps responses fast while the CDN refreshes behind them. A single TTL for the pyramid is either wasted origin traffic at the top or hours of stale data at the bottom. The exception is a version-stamped prefix, where every tile is immutable and one long TTL is correct.
Do I need CORS on the tile bucket?
Yes, whenever the map page and the tiles are on different origins. MapLibre GL and Mapbox GL fetch vector tiles through the fetch API rather than as images, so the same-origin policy applies and the browser discards the response without an Access-Control-Allow-Origin header. Allow GET and HEAD for your map origins, expose Content-Encoding, and set maxAgeSeconds high enough that preflights are not repeated across a panning session.
How do I purge one region after re-rendering it?
Prefer not to purge at all. Invalidation matches path prefixes rather than bounding boxes, so a 40 km square at zoom 14 means either invalidating the whole zoom level or submitting hundreds of paths to a rate-limited API that takes minutes to propagate. Render into a new versioned prefix and point the style at it: the swap is atomic for every new session, old tiles expire on their own, and the tiles can honestly be marked immutable. Keep invalidation for one-off hotfixes.
Related
- Vector Tile Pipeline with Cloud Run and Pub/Sub — the pipeline that produces the pyramid this page serves
- Generating MVT Tiles with Tippecanoe in Cloud Run — where
--no-tile-compressionis set and why the uploader owns gzip - Tuning HTTP Range Requests for COG Reads on S3 — the raster-side equivalent of getting HTTP semantics right on object storage
- IAM Security Boundaries for Cloud GIS — keeping a publicly readable tiles bucket separate from everything else
- SQS and Pub/Sub Queue Routing Strategies — the messaging layer that triggers the re-render this page then has to publish