Serving PMTiles from Object Storage Without a Tile Server

Part of the Tile vs Vector Rendering Strategies guide.

Operative rule: a single-file tile archive is only as good as its CORS configuration — the storage must accept a Range request header and expose Content-Range and Accept-Ranges to the page’s origin, or the archive is unreadable from a browser.

How One File Becomes Millions of Tiles

A single-file archive stores every tile in the pyramid back to back, preceded by a directory that maps each z/x/y address to a byte offset and length. A client reads the header and root directory once — a few kilobytes — and from then on requests exactly the bytes for the tiles in view using HTTP range requests. Large archives use nested directories, so a deep zoom may cost one extra directory fetch before the tile itself.

The practical consequence is that the “tile server” disappears. There is no process to run, scale, patch or pay for while idle; the whole pyramid is one object in the same storage as the rest of the site, and the CDN in front of it caches byte ranges exactly as it caches anything else.

Inside a single-file tile archive The archive starts with a header and root directory holding metadata and offsets. Leaf directories follow for deeper zoom levels. The bulk of the file is the tile data section. A client fetches the header once, then a leaf directory when needed, then the exact byte range of each tile. One object, three regions, three kinds of request header + root dir leaf directories tile data — every tile in the pyramid, back to back request 1 — bytes 0–16 KB: header and root directory, fetched once per session request 2 — a leaf directory, only when the reader zooms into a new branch request 3…n — one exact byte range per visible tile, cached by the CDN like any object

Production-Ready Configuration

The Python side is unchanged: cut tiles as usual, then convert the archive and publish it. What needs care is the storage response headers.

from __future__ import annotations

import subprocess
from pathlib import Path


def build_archive(geojson: Path, out: Path, min_zoom: int = 4,
                  max_zoom: int = 14) -> Path:
    """Cut tiles, then convert to a single-file archive for range serving."""
    mbtiles = out.with_suffix(".mbtiles")
    subprocess.run(
        ["tippecanoe", "-o", str(mbtiles), "--force",
         "-Z", str(min_zoom), "-z", str(max_zoom),
         "--drop-densest-as-needed", "--generate-ids",
         str(geojson)],
        check=True,
    )
    subprocess.run(["pmtiles", "convert", str(mbtiles), str(out)], check=True)
    return out


UPLOAD_HEADERS = {
    # Immutable because the filename carries a content hash.
    "Cache-Control": "public, max-age=31536000, immutable",
    "Content-Type": "application/octet-stream",
}

CORS_RULES = [
    {
        "AllowedOrigins": ["https://dashboard.example.org"],
        "AllowedMethods": ["GET", "HEAD"],
        "AllowedHeaders": ["Range", "If-Match", "If-None-Match"],
        # Without these three exposed, the browser fetches but the client
        # library cannot read the response — the classic silent failure.
        "ExposeHeaders": ["Content-Range", "Content-Length", "Accept-Ranges", "ETag"],
        "MaxAgeSeconds": 3600,
    }
]

ExposeHeaders is the line that breaks most first deployments. A cross-origin response’s headers are hidden from JavaScript unless the server names them explicitly, so the range request succeeds at the network level and the library sees a response it cannot interpret. The symptom is a map that loads its style, requests the archive, and then renders nothing at all.

What Replaces the Tile Server

Tile server versus single-file range serving A tile server needs a process to run, patch and scale, costs money while idle and can generate tiles on demand. Range serving needs no process, scales with the CDN, costs nothing while idle, and requires the pyramid to be generated ahead of time. What you gain, and what you give up running tile server a process to patch, monitor and scale costs money while nobody is looking can render tiles on demand can filter per request or per tenant right when tiles depend on the viewer single-file range serving no process at all free while idle, scales with the CDN the pyramid must exist before it is served one artifact to publish and roll back right when every viewer sees the same tiles Rollback becomes a pointer change, because the whole pyramid is one immutable object.

Caching and Versioning the Archive

Because the archive is a single object, cache behaviour is simple and worth getting right on the first deploy. Give the file a content-hashed name and an immutable, year-long cache lifetime; then a rebuild produces a new name and no purge is ever needed. The only object that must revalidate is the small manifest that tells the page which archive is current.

Two objects, two cache policies The archive is named by content hash, cached immutably for a year and never purged. A small manifest names the current archive and is fetched with revalidation on every load, so a new build is picked up immediately without invalidating anything. Two objects, and only one of them may ever be stale tiles.<hash>.pmtiles — immutable, max-age one year a rebuild writes a new name, so nothing is ever invalidated or purged range requests hit the CDN like any other object manifest.json — no-cache, revalidated on every load names the current archive and the data's source timestamp a few hundred bytes — the only request that must not be served stale Keep the previous archive until the manifest has propagated, so a rollback needs no rebuild.

Verification Steps

  • Issue a HEAD request and confirm Accept-Ranges: bytes is present.
  • Issue a range request from the browser console on the dashboard’s origin and confirm Content-Range is readable from JavaScript.
  • Load the map with the network panel filtered to the archive and confirm requests are partial, not a full download.
  • Confirm the first paint costs a small number of requests, not one per tile plus one per directory.
  • Roll the manifest back to the previous archive and confirm the map returns to it without a rebuild.

Common Errors & Fixes

The map is blank and the archive request returned 200

The whole file was fetched because ranges were refused, or the range headers were not exposed. Check Accept-Ranges and the CORS ExposeHeaders list.

Everything works from the same origin and fails when embedded

The CORS rule names the dashboard origin but not the embedding host. Add every origin that will load the map, and remember an embedded map has its own origin.

The first zoom into a new area is slow

That is the leaf-directory fetch. It is expected and only happens once per branch, but if it is frequent the archive’s directory layout can be tuned when it is generated.

The map shows old data after a rebuild

The manifest is being cached. It is the one object that must revalidate; everything else is immutable by name.

Access Control Without a Server

The obvious question about serving a whole pyramid as one public object is what to do when the data is not public. Removing the tile server removes the natural place to check who is asking, and the answer has to come from the storage layer instead.

Three patterns cover almost every case. A signed URL with a short expiry works when the page itself is already behind authentication: the application issues a signed link for the archive when it renders the dashboard, and the browser’s range requests carry the signature. The trade is that the signature has a lifetime, so a session longer than the expiry needs the page to refresh it — which is a small amount of code but must be written before the first reader hits the boundary.

A signed cookie works better for long sessions, because the browser attaches it to every request automatically, including range requests, without the page having to manage anything. It requires that the storage or the CDN in front of it can validate cookies, which not every provider supports.

The third pattern is a thin authorising proxy that validates a session and streams the range through. It reintroduces a process — the thing this whole approach removed — but a proxy that only checks a token and forwards a byte range is a far smaller thing to operate than a tile server, and it can be a small edge function rather than a fleet.

What does not work is obscurity. An archive at an unguessable path is public to anyone who has ever been sent the link, and links travel: into chat histories, into browser sync, into screenshots. If the data genuinely requires access control, it needs one of the three mechanisms above rather than a long random filename.

It is worth deciding this before the first deployment rather than after, because the choice affects the URL structure the style document references, and changing it later means regenerating every style that points at the archive.

Gotchas & Edge Cases

  • Some CDNs cache a full-object response and then answer subsequent range requests from it, which works but transfers the whole archive once per edge — check the first request’s size, not just the later ones.
  • A HEAD request that returns no Content-Length breaks clients that size their first range from it; confirm the header survives the proxy in front of storage.
  • Compression at the edge can defeat range requests entirely, because the byte offsets no longer refer to the stored bytes. Serve the archive uncompressed; its contents are already compressed.
  • Very large archives use nested directories, so the first zoom into a new branch costs an extra request — expected behaviour, not a fault, but worth knowing when reading a waterfall.
  • A partially uploaded archive is still readable at its header and will render some tiles and not others; verify the object’s size after upload before switching the pointer to it.