Serving PMTiles from Object Storage Jump to heading
Put one file in a bucket, point a map at it, and have no tile server to operate — provided the storage, the edge network and the headers all cooperate on one thing: HTTP range requests.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A PMTiles archive is one file laid out so that a client can find any tile with a small number of byte-range reads. Its head holds a header and a hierarchical directory; tile data follows. A client reads the header once, walks the directory — usually one or two additional range reads, cached thereafter — and then fetches the tile’s bytes directly.
Everything that makes this work is a property of the transport rather than of the file. Range requests must be honoured end to end: by the object store, by the edge network, and by any proxy in between. A layer that answers a range request with the whole file turns each tile fetch into a multi-gigabyte download. CORS headers must permit the range header from browser clients, which is a separate allowance from permitting the request itself.
The consequence of the single-file design is immutability in practice. Updating one tile means rewriting the archive, so PMTiles fits a tile set rebuilt as a unit and fits a minutely-updated one badly.
Runnable solution Jump to heading
#!/usr/bin/env bash
# Convert an MBTiles archive to PMTiles and publish it.
set -euo pipefail
SRC="${1:?usage: publish.sh <archive.mbtiles>}"
BASE="$(basename "$SRC" .mbtiles)"
VERSION="$(date -u +%Y%m%dT%H%M%SZ)" # a version token in the object key
DST="${BASE}-${VERSION}.pmtiles"
BUCKET="s3://tiles.example.org"
pmtiles convert "$SRC" "$DST"
pmtiles show "$DST" # prints zoom range, bounds, layers
# Tiles are immutable per version, so they can be cached effectively forever.
aws s3 cp "$DST" "$BUCKET/$DST" \
--content-type application/octet-stream \
--cache-control "public, max-age=31536000, immutable"
echo "https://tiles.example.org/$DST"
from __future__ import annotations
import logging
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.pmtiles.verify")
ORIGIN = "https://map.example.org" # the site that will embed the map
def check(url: str) -> bool:
ok = True
head = requests.head(url, timeout=30)
head.raise_for_status()
size = int(head.headers.get("Content-Length", 0))
logger.info("archive is %.1f MiB", size / (1 << 20))
if head.headers.get("Accept-Ranges") != "bytes":
logger.error("origin does not advertise byte ranges")
ok = False
# The decisive test: ask for 16 bytes and insist on getting 16 bytes.
ranged = requests.get(url, headers={"Range": "bytes=0-15"}, timeout=30)
if ranged.status_code != 206:
logger.error("range request returned %d, expected 206 Partial Content",
ranged.status_code)
ok = False
elif len(ranged.content) != 16:
logger.error("range request returned %d bytes, expected 16 — a layer "
"in the path is collapsing ranges", len(ranged.content))
ok = False
else:
logger.info("range requests honoured; magic bytes %r", ranged.content[:7])
# Browsers send a preflight for the Range header; it must be allowed.
pre = requests.options(url, headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "range",
}, timeout=30)
allowed = pre.headers.get("Access-Control-Allow-Headers", "").lower()
if "range" not in allowed:
logger.error("CORS preflight does not allow the Range header (got %r)",
allowed or "nothing")
ok = False
expose = pre.headers.get("Access-Control-Expose-Headers", "").lower()
if "content-length" not in expose and "*" not in expose:
logger.warning("Content-Length not exposed to the browser; some "
"readers need it")
return ok
if __name__ == "__main__":
logger.info("verification %s", "PASSED" if check(sys.argv[1]) else "FAILED")
Step-by-step walkthrough Jump to heading
- Put the version in the object key. A dated file name makes every rebuild a new URL, which lets the archive be cached indefinitely and removes the browser-cache problem entirely.
- Set an immutable cache policy. Because the key changes on every rebuild, the object behind it never changes, so a long lifetime with an immutable directive is both safe and the cheapest possible configuration.
- Inspect after converting. The conversion reports the zoom range, bounds and layer list; comparing that against what the build intended catches a truncated or wrong archive before it is uploaded.
- Test the range request, not the header. Advertising byte-range support and honouring it are different things. Asking for sixteen bytes and counting what arrives is the only test that distinguishes them.
- Insist on a partial-content status. A layer that answers a range request with the whole file returns a normal success status, which is exactly the failure this check exists to find.
- Check the CORS preflight for the range header. Browsers send a preflight naming
Range; a configuration that allows the origin but not that header fails only in a browser, never in a command-line test. - Watch for exposed headers. Some client readers need
Content-Lengthvisible to the page, which requires it to be explicitly exposed in the CORS response.
Verification Jump to heading
- A sixteen-byte range returns sixteen bytes. Anything else means a layer in the path is collapsing ranges.
- The status is partial content. A plain success status on a range request is the signature of the same problem.
- The preflight allows the range header. Test with an options request carrying the origin and the requested header.
- The archive’s metadata matches the build. Zoom range, bounds and layer list should be exactly what the generator produced.
- A cold client loads in a handful of requests. Watch the network panel: header, directory, then tiles. A long sequence of directory reads means the archive is deeply nested and may benefit from reclustering.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Whole archive downloaded per tile | A layer collapses range requests | Test with a small range and require partial content |
| Map loads from the shell, not the browser | CORS preflight rejects the range header | Allow Range in the allowed-headers policy |
| Map loads then stalls | Content-Length not exposed to scripts |
Expose it in the CORS response |
| Readers see a stale archive | Object key reused across rebuilds | Put a version token in the object key |
| Archive rewritten every few minutes | Immutable format with incremental updates | Use an updateable archive format instead |
| Client reports an unsupported version | Archive written by an older tool | Convert with a current toolchain and re-inspect |
| High cost per request | Cache lifetime too short for immutable objects | Set a long lifetime and rely on versioned keys |
Specification reference Jump to heading
A PMTiles archive is a single file beginning with a fixed-size header followed by a hierarchical directory structure and then tile data, designed so that a client can locate and retrieve an individual tile using HTTP range requests without a server-side component. Clients require the storage layer and any intermediaries to honour range requests and, for browser use, to permit the
Rangeheader through CORS. See the PMTiles specification for the header layout and directory format.
Frequently Asked Questions Jump to heading
Why does my map work from the command line but not in a browser?
Almost always CORS. A browser sends a preflight request naming the Range header before it will issue a ranged fetch to another origin, and a configuration that permits the origin but not that specific header rejects the preflight. Command-line clients do not preflight, so the same archive works perfectly from a shell. Test with an explicit options request carrying the origin and the requested header.
How do I know range requests are really being honoured?
Ask for a small range and count the bytes that come back. A layer that ignores ranges returns the entire file with an ordinary success status, which looks like success to every naive check. Requiring a partial-content status and exactly the requested number of bytes distinguishes the two unambiguously, and it is worth running against the public URL rather than the origin, because an edge network is a common place for ranges to be lost.
Can I update a single tile in a published archive?
Not practically. The format is laid out for read efficiency rather than in-place modification, so changing a tile means rewriting the file. That is fine for a tile set rebuilt as a unit on a daily or weekly cadence, and unworkable for one updated from minutely diffs. If your tile set changes continuously, use an updateable archive behind a server instead.
What cache lifetime should I set?
A very long one, provided the object key carries a version token. Because a rebuild produces a new key, the object behind any given URL never changes, so there is no reason for a cache anywhere in the path ever to revalidate it. That combination — versioned keys plus an immutable, long-lived cache policy — is what makes serverless tile delivery cheap.
Related Jump to heading
- Serving & Invalidating OSM Tiles — the parent topic and the format comparison behind this choice.
- Invalidating Tile Caches After an OSM Diff — the approach for archives that do change in place.
- Generating MBTiles from OSM GeoJSON — producing the archive this converts.
- Running Planetiler on a Regional Extract — a generator that can emit this format directly.
- Automating ODbL Attribution in Derived Products — the attribution the archive metadata must carry.
Up one level: Serving & Invalidating OSM Tiles.