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.
Core Algorithmic Pipeline
- Check out the tag and verify its signature before building anything.
- Build one tile archive rather than millions of objects, so publication is a single atomic upload.
- Upload under a version path with a long cache lifetime and a content hash recorded in the manifest.
- Verify the published archive by fetching a sample of tiles through the public URL.
- Swap the pointer document last, with a short cache lifetime, and announce the retirement date of the previous version.
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"
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-ageon 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.
Related
- Release Tagging Strategies for Spatial Basemaps β the parent guide and the signed tag this publishes from
- Versioning PMTiles Archives for Map Releases β the archive format and how it is versioned in the repository
- Automating Basemap Release Tags with CI Pipelines β producing the tag this script consumes
- Versioning FlatGeobuf Streams for Web Delivery β the same publication pattern for vector streams