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.

bash
# ---- 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:

json
[
  {
    "AllowedOrigins": ["https://maps.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["Range", "If-Match"],
    "ExposeHeaders": ["Content-Range", "Content-Length", "ETag", "Accept-Ranges"],
    "MaxAgeSeconds": 3600
  }
]
bash
# 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:

javascript
// 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:

bash
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