Skip to content

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.

The metadata every .pbf object and the tile bucket must carryFive settings applied to a served vector tile, in the order they are set. Content-Type application/x-protobuf identifies the payload. Content-Encoding gzip tells the browser to inflate before parsing. The no-transform token in Cache-Control stops Cloud Storage from decompressing the object and dropping that header. A per-zoom max-age controls how long the CDN holds the tile. Bucket CORS allows the map's origin to read the response at all.Five settings decide whether a static pyramid works1Declare the payloadnot octet-stream — some clients and proxies sniff the typeapplication/x-protobuf2Declare the compressionwithout it the browser hands gzip bytes to the MVT parser and draws nothingContent-Encoding: gzip3Forbid transcodinga client without Accept-Encoding otherwise gets a decompressed object with the encoding headerstrippedno-transform4Set the lifetime for this zoomone hour at z14, thirty days at z0-z6, one year under a versioned prefixmax-age=3600 … 315360005Allow the map's originMapLibre uses fetch(), so no CORS header means no tile at allGET, HEAD
Only the fourth is a performance setting. The other four are correctness: get any of them wrong and the map is blank while every request returns HTTP 200.

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_STATIC so origin Cache-Control headers are honoured. FORCE_CACHE_ALL overrides 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:
    code
    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.

Cache lifetime for each zoom band of a parcel tile pyramidFour cache lifetimes in hours for a parcel pyramid. Zooms 0 to 6, about 5,500 tiles that never change, are cached for 720 hours or 30 days. Zooms 7 to 11 are cached for 168 hours or seven days. Zooms 12 to 14, where parcel edits become visible, are cached for one hour with a day of stale-while-revalidate. Any tile published under a version-stamped prefix is immutable and cached for 8,760 hours, a full year.How long a tile should live in the cache, by zoom bandz0-z6 — world and country outline30 daysz7-z11 — regional generalisation7 daysz12-z14 — where parcel edits land1 h + SWR 24 h/v7/ versioned prefix, any zoom1 year, immutable0hours of cache lifetimeAbout 5,500 tiles sit in the first band and 1.4 million in the third: the short TTL applies to almost every object and almost none of the requests.
The bottom of the pyramid holds the data and the top holds the traffic, so one TTL for the whole thing is wrong at one end or the other. Versioning the prefix collapses the choice into the last row.

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.

python
# 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:

bash
# 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

Cache invalidation compared with a version-prefix swap after a regional re-renderTwo panels comparing how to make a regional re-render visible. Invalidating the CDN path purges by prefix rather than by bounding box, is a rate-limited control-plane operation that takes minutes to propagate, and empties a whole zoom level to fix under a thousand tiles. Swapping to a new version prefix writes the new pyramid to a fresh path, flips one string in the style, keeps the old tiles serving until they expire, and lets every tile be marked immutable, at the cost of storing two copies until a lifecycle rule removes the old prefix.One municipality re-renders 40 km of parcelsInvalidate the cached pathgcloud compute url-maps invalidate-cdn-cache --path'/parcels/14/*'Matches path prefixes, never bounding boxes: purgesevery z14 tile in the country to fix 729 of themControl-plane operation, minutes to propagate, and ratelimited — 980 individual paths is not viableThe emptied zoom level sends every subsequent request tothe origin until it refillsSwap the version prefixRender into /v8/parcels/, publish the style pointing at itAtomic for every new session; in-flight clients keepserving /v7/ until they reloadTiles are genuinely immutable, so max-age=31536000 ishonest rather than optimisticRollback is changing one string back; the cost is two copiesuntil a lifecycle rule deletes /v7/Version-swap for any scheduled or regional re-render. Keep invalidation for a single bad tile that has to be gone inminutes.
The re-rendered area is about 980 tiles across z12 to z14. Neither the invalidation API nor the cache key can express that shape, which is what makes the version swap the default and invalidation the exception.

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.

bash
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:

code
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 tippecanoe gzips the tile and the uploader gzips it again, the object is valid gzip, Content-Encoding: gzip is technically true, and the browser inflates exactly once — leaving the parser holding compressed bytes. This is why --no-tile-compression belongs on both tippecanoe and tile-join, and why the uploader owns compression alone.
  • FORCE_CACHE_ALL silently discards the per-zoom policy. It is the fastest way to get a cache hit ratio in a demo and it makes the entire TTL_BY_ZOOM table 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=8 to 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=0 in 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.


Back to Vector Tile Pipeline with Cloud Run and Pub/Sub