Publishing a Versioned Tile Set from a Release Tag

A tile set is the most-read artifact a mapping team produces and the one most often published without a version, which is why β€œthe map looks wrong on my machine and fine on yours” is such a familiar sentence. This page is a focused companion to release tagging strategies for spatial basemaps.

Concept & Context

Tiles are cached aggressively β€” by browsers, by mobile clients, by CDN edges, by offline packages. That is the point: it is what makes a map fast. It also means that publishing over an existing tile path leaves the world in a mixed state for as long as the longest cache lifetime, and during that window different clients see different releases.

The fix is the same one that works for any heavily cached artifact: make the tile URLs immutable and move a tiny pointer instead. Tiles live under a path containing the version, so a given URL always returns the same bytes and can carry a one-year cache lifetime. The only object that changes is a small style or catalogue document naming the current version, with a short cache lifetime.

Building from the tag rather than the branch matters for the same reason the tag exists. A tile set built from main is built from whatever main was at that moment; one built from v2026.08 can be rebuilt byte for byte a year later and compared against what was published.

One mutable object, everything else immutable Three bands describing the publication layout: clients read a small catalogue document with a short cache lifetime, which names a version, which resolves to a tile archive under an immutable path cached for a year. MUTABLE, SHORT CACHE catalogue.json β€” max-age 120 NAMES A VERSION v2026.08 IMMUTABLE, YEAR CACHE tiles/v2026.08/basemap.pmtiles tiles/v2026.07/basemap.pmtiles names resolves to Publishing over a stable tile path leaves caches serving two releases at once; this layout makes that impossible.

Core Algorithmic Pipeline

  1. Check out the tag and verify its signature before building anything.
  2. Build one tile archive rather than millions of objects, so publication is a single atomic upload.
  3. Upload under a version path with a long cache lifetime and a content hash recorded in the manifest.
  4. Verify the published archive by fetching a sample of tiles through the public URL.
  5. Swap the pointer document last, with a short cache lifetime, and announce the retirement date of the previous version.
Upload, verify, then move the pointer A sequence in which the release job verifies the tag signature, builds the archive, uploads it under a version path, verifies it through the public URL, and only then updates the catalogue document that clients read. Release job Tag Storage Client verify the signature upload tiles/v2026.08/… immutable path, year-long cache fetch back and verify the digest update catalogue.json the only mutable object new version on next poll Any other order leaves a window where the catalogue names an archive nobody can fetch.

Working Implementation

#!/usr/bin/env bash
# publish_tiles.sh β€” build and publish a tile set from a signed release tag.
set -euo pipefail

TAG="${1:?usage: publish_tiles.sh vYYYY.MM[-region]}"
BUCKET="s3://basemaps.example.org"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

echo "==> Verify and check out ${TAG}"
git verify-tag "$TAG"                     # refuse to publish an unsigned release
git switch --detach "$TAG"
dvc pull --quiet

echo "==> Build one archive, not a tree of objects"
tippecanoe -o "$WORK/basemap.pmtiles" \
  --force \
  --maximum-zoom=14 --minimum-zoom=4 \
  --drop-densest-as-needed \
  --name="Basemap ${TAG}" \
  --attribution="Β© Contributors" \
  data/national/parcels.geojson data/national/roads.geojson

ARCHIVE_SHA=$(sha256sum "$WORK/basemap.pmtiles" | cut -d' ' -f1)
echo "    archive sha256 ${ARCHIVE_SHA}"

echo "==> Upload under an immutable version path"
aws s3 cp "$WORK/basemap.pmtiles" "${BUCKET}/tiles/${TAG}/basemap.pmtiles" \
  --cache-control "public, max-age=31536000, immutable" \
  --content-type "application/vnd.pmtiles" \
  --metadata "release=${TAG},sha256=${ARCHIVE_SHA}"

echo "==> Verify through the public URL before anything points at it"
python scripts/verify_tiles.py \
  --url "https://basemaps.example.org/tiles/${TAG}/basemap.pmtiles" \
  --expect-sha256 "${ARCHIVE_SHA}" \
  --sample 24

echo "==> Move the pointer last"
jq --arg tag "$TAG" --arg sha "$ARCHIVE_SHA" \
   '.current = $tag | .archive_sha256 = $sha | .published_at = (now | todate)' \
   catalogue.json > "$WORK/catalogue.json"

aws s3 cp "$WORK/catalogue.json" "${BUCKET}/catalogue.json" \
  --cache-control "public, max-age=120" \
  --content-type "application/json"

echo "==> Published ${TAG}. Previous release stays live for 30 days."
git switch -
# scripts/verify_tiles.py β€” prove the published archive serves real tiles
"""Fetch the published archive's header and a sample of tiles over HTTP range
requests, exactly as a client would, before the pointer is moved to it."""
from __future__ import annotations

import argparse
import hashlib
import random
import sys
import urllib.request


def ranged(url: str, start: int, length: int) -> bytes:
    req = urllib.request.Request(url, headers={"Range": f"bytes={start}-{start + length - 1}"})
    with urllib.request.urlopen(req, timeout=30) as resp:
        if resp.status != 206:
            raise RuntimeError(f"range requests not honoured (status {resp.status})")
        return resp.read()


def whole(url: str) -> bytes:
    with urllib.request.urlopen(url, timeout=600) as resp:
        return resp.read()


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--url", required=True)
    ap.add_argument("--expect-sha256", required=True)
    ap.add_argument("--sample", type=int, default=16)
    args = ap.parse_args()

    header = ranged(args.url, 0, 127)
    if not header.startswith(b"PMTiles"):
        raise SystemExit("published object is not a PMTiles archive")

    body = whole(args.url)
    digest = hashlib.sha256(body).hexdigest()
    if digest != args.expect_sha256:
        raise SystemExit(f"published bytes hash to {digest}, expected {args.expect_sha256}")

    from pmtiles.reader import Reader, MmapSource
    import io
    reader = Reader(MmapSource(io.BytesIO(body)))
    rng = random.Random(20260806)
    empty = 0
    for _ in range(args.sample):
        z = rng.randint(6, 12)
        x = rng.randrange(0, 2 ** z)
        y = rng.randrange(0, 2 ** z)
        if reader.get(z, x, y) in (None, b""):
            empty += 1

    if empty == args.sample:
        raise SystemExit("every sampled tile is empty β€” the archive built no data")
    print(f"verified: header, digest, and {args.sample - empty}/{args.sample} sampled tiles")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Sampling tiles rather than only checking the digest catches the archive that uploaded perfectly and contains nothing, which happens when the build silently produced an empty input.

Validation & Output Verification

# Version paths must be immutable in practice, not only by convention
aws s3api head-object --bucket basemaps.example.org \
  --key "tiles/v2026.07/basemap.pmtiles" \
  --query 'CacheControl' --output text     # expected: ... immutable

# The pointer must be short-lived, or clients will not see new releases
curl -sI https://basemaps.example.org/catalogue.json | grep -i cache-control
# expected: max-age=120

# The previous release must still serve while its retirement window runs
curl -sI -H "Range: bytes=0-127" \
  https://basemaps.example.org/tiles/v2026.07/basemap.pmtiles | head -1
# expected: HTTP/2 206
# The published archive must be rebuildable from the tag
import subprocess, hashlib, json
subprocess.run(["git", "switch", "--detach", "v2026.08"], check=True)
subprocess.run(["bash", "scripts/build_tiles.sh", "/tmp/rebuild.pmtiles"], check=True)
rebuilt = hashlib.sha256(open("/tmp/rebuild.pmtiles", "rb").read()).hexdigest()
published = json.load(open("catalogue.json"))["archive_sha256"]
assert rebuilt == published, "the tag does not reproduce the published tiles"
The support window a cached pointer requires A timeline of a release: the new version is published and the pointer moves, cached pointers expire over the following minutes and hours, offline clients refresh over days, and the previous version is only retired after the announced window. Pointer moved new version live T Web clients switch catalogue cache expires T+2 min Offline clients refresh on their own schedule T+7 d Previous retired announced in the release T+30 d Retiring at the second marker is what produces the support ticket that says the map went blank.

Failure Modes

  • Clients render mismatched geometry across tile boundaries β€” symptom: a seam of misaligned features at one zoom. Root cause: publishing over a stable path while caches hold a previous release. Fix: immutable version paths and a pointer swap.

  • A release is announced before it can be fetched β€” symptom: consumers get 404s. Root cause: the pointer moved before the upload finished. Fix: upload, verify through the public URL, then swap β€” in that order.

  • Nobody sees the new release β€” symptom: clients stay on an old version for days. Root cause: the pointer document inherited the same long cache lifetime as the tiles. Fix: short max-age on the pointer, long on version paths.

  • The published tiles cannot be rebuilt β€” symptom: a rebuild from the tag produces a different archive. Root cause: the build ran from a branch, or an unpinned tiler version. Fix: build from the verified tag with a pinned toolchain, as provenance and lineage tracking requires of any published artifact.

Back to Release Tagging Strategies for Spatial Basemaps