Configuring R2 and S3 for PMTiles Range Requests
Both Cloudflare R2 and Amazon S3 serve HTTP range requests out of the box — a GET with a Range header returns 206 Partial Content without any special setting. The configuration work is therefore not “turn on ranges” but three adjacent concerns: the object’s Content-Type, its Cache-Control, and a CORS policy that lets browser JavaScript both send Range and read the Content-Range it gets back. Get those three right and a .pmtiles archive serves as a fully static tileset with no server process.
When to Use This
Use static object-storage hosting when the tileset is rebuilt on a schedule rather than per-request, when you want zero server operations, and when you can publish each rebuild under a new versioned prefix. If tiles are generated live from PostGIS, or the dataset changes faster than you can rebuild an archive, a dynamic tile server is the right layer instead. The parent PMTiles range-request delivery section covers the read pattern this configuration enables.
Specification: Bucket and Object Settings
| Setting | Value | Why |
|---|---|---|
Content-Type |
application/octet-stream (or application/vnd.pmtiles) |
Opaque binary; must not be sniffed or transformed |
Cache-Control |
public, max-age=31536000, immutable |
Object is immutable under a versioned prefix |
CORS AllowedMethods |
GET, HEAD |
Range reads are GETs; HEAD is used for probes |
CORS AllowedHeaders |
Range, If-Match |
Browser must be permitted to send Range |
CORS ExposeHeaders |
Content-Range, Content-Length, ETag, Accept-Ranges |
JS must be able to read range metadata |
CORS AllowedOrigins |
your map origin (or *) |
Scopes which sites may fetch the archive |
application/octet-stream is the safe, universally understood choice; application/vnd.pmtiles is the more descriptive registered type and works equally well as long as the CDN does not try to compress it. The immutable directive is only safe because the versioned prefix guarantees the bytes at a URL never change — the same rule the cache-control headers for immutable vector tiles page applies to /{z}/{x}/{y}.pbf paths.
Production Command Block
The full sequence: upload with headers, apply CORS, and (for R2) expose the object through a custom domain or Worker so the CDN sits in front.
# ---- Cloudflare R2 ----
wrangler r2 object put "tiles/v3a1b2c3/roads.pmtiles" \
--file roads.pmtiles \
--content-type application/octet-stream \
--cache-control "public, max-age=31536000, immutable"
# ---- Amazon S3 ----
aws s3api put-object \
--bucket tiles.example.com \
--key v3a1b2c3/roads.pmtiles \
--body roads.pmtiles \
--content-type application/octet-stream \
--cache-control "public, max-age=31536000, immutable"
The CORS policy is a JSON document applied to the bucket, not the object:
[
{
"AllowedOrigins": ["https://maps.example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "If-Match"],
"ExposeHeaders": ["Content-Range", "Content-Length", "ETag", "Accept-Ranges"],
"MaxAgeSeconds": 3600
}
]
# Apply the CORS rule
aws s3api put-bucket-cors --bucket tiles.example.com --cors-configuration file://cors.json
wrangler r2 bucket cors put tiles --file cors.json
For R2, either bind a custom domain to the bucket (so tiles.example.com maps straight to the bucket through Cloudflare’s cache) or front it with a minimal Worker that forwards the Range header untouched:
// R2 custom-domain Worker: forward range reads to the bucket verbatim
export default {
async fetch(request, env) {
const url = new URL(request.url);
const key = url.pathname.slice(1); // strip leading /
const range = request.headers.get("range");
const object = await env.TILES.get(key, range ? { range: parseRange(range) } : undefined);
if (!object) return new Response("Not found", { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers); // content-type, cache-control
headers.set("accept-ranges", "bytes");
headers.set("access-control-allow-origin", "https://maps.example.com");
headers.set("access-control-expose-headers", "Content-Range, Content-Length, ETag, Accept-Ranges");
if (object.range) {
headers.set("content-range", `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`);
return new Response(object.body, { status: 206, headers });
}
return new Response(object.body, { headers });
}
};
Confirm the archive actually serves ranges before wiring MapLibre to it:
curl -s -o /dev/null -D - \
-H "Origin: https://maps.example.com" \
-H "Range: bytes=0-16383" \
"https://tiles.example.com/v3a1b2c3/roads.pmtiles" \
| grep -iE "http/|content-range|accept-ranges|access-control-(allow-origin|expose)"
# Expect: HTTP/2 206, Content-Range: bytes 0-16383/<total>, Accept-Ranges: bytes,
# plus the two Access-Control headers
Interaction Effects
The Cache-Control value here is the same immutable directive the cache-headers section prescribes, and it is only correct in combination with prefix rotation — an immutable object under a stable URL would pin a stale tileset forever. The CORS ExposeHeaders list directly feeds the pmtiles:// protocol handler: pmtiles.js reads Content-Range to confirm each slice, so an otherwise-correct upload with a missing expose rule still breaks range reads at the JavaScript layer. And range forwarding is a property of every hop, not just the origin — a CDN or Worker that buffers the full object undoes the bucket’s native 206 support.
Performance Impact
R2’s headline advantage is zero egress: range GETs against an R2 bucket incur no per-byte transfer charge, so a busy basemap costs only Class B operations. S3 bills both the request and the transferred bytes of every range, which for a high-traffic map is a real line item — mitigated, but not eliminated, by a high CDN cache-hit ratio. On the latency side, caching individual byte ranges at the edge turns the cold-tile cost of two-to-three round trips into a single edge hit for any tile whose leaf directory and data range are already warm, which is why a versioned, immutable object with a long max-age reaches the > 95% edge hit ratio a stable basemap should target.
Common Mistakes
Missing ExposeHeaders → JS cannot read Content-Range. The most common misconfiguration. The 206 arrives correctly at the network layer, but the browser hides Content-Range from fetch() unless it is explicitly exposed, so pmtiles.js cannot validate the slice and the map fails silently with no HTTP error. Add Content-Range and Content-Length to ExposeHeaders and re-apply the policy.
Wrong content-type. Uploading with text/html or a sniffed type invites a CDN to transform or compress the body. Force application/octet-stream (or application/vnd.pmtiles) on put-object.
S3 server-side encryption with customer keys (SSE-C) breaking ranges. SSE-C requires the encryption headers on every GET, including ranged ones; a plain ranged curl without them returns 400, which surfaces as a broken map. Use SSE-S3 (AES256) or bucket-default encryption, which are transparent to range reads, rather than SSE-C for publicly served tilesets.
CDN stripping the Range header. Some proxy configurations drop Range on cache-miss and fetch the whole object, then return a 200. If a HEAD shows Accept-Ranges: bytes at the origin but the browser still downloads the whole file, the intermediary is the culprit — the deeper diagnosis is in debugging HTTP 416 range-request failures.
Parent: PMTiles Range-Request Delivery
Related
- Debugging HTTP 416 Range-Request Failures for PMTiles — the diagnostic decision tree for when a correctly configured bucket still fails to serve ranges to the browser.
- Cache-Control Headers for Immutable Vector Tiles — why the immutable, one-year
max-agevalue used here is safe only under a versioned prefix.