PMTiles Hosting Guide
@bloomsparkagency/core uses PMTiles for all tile serving — both basemap and per-building archives. PMTiles is a single-file, range-request- friendly format hostable on any blob store with zero tile server runtime.
We recommend one PMTiles archive per building so customers can swap, replace, or tier-cache buildings independently.
1. Cloudflare R2 + Worker (recommended)
Cloudflare R2 + the Protomaps Cloudflare Worker gives the lowest cost and lowest latency for global CDN tile delivery.
Step 1 — Create an R2 bucket
wrangler r2 bucket create spatial-tilesStep 2 — Upload PMTiles archives
The Cloudflare web UI is limited to 300 MB. For larger archives, use rclone:
rclone copy ./building-42.pmtiles spatial-r2:spatial-tiles \
--s3-upload-cutoff=200M \
--s3-chunk-size=200MFor the global basemap (typically >1 GB):
rclone copy ./protomaps-2026-04.pmtiles spatial-r2:spatial-tiles \
--s3-upload-cutoff=200M \
--s3-chunk-size=200MStep 3 — Deploy the Protomaps Cloudflare Worker
Clone and deploy the Protomaps Cloudflare Worker:
git clone https://github.com/protomaps/PMTiles
cd PMTiles/serverless/cloudflare
npm install
wrangler deploySet the following Worker variables in wrangler.toml or the Cloudflare dashboard:
| Variable | Example value |
|---|---|
ALLOWED_ORIGINS | https://app.customer.com,https://localhost:3000 |
PMTILES_PATH | {name}.pmtiles |
CACHE_CONTROL | public, max-age=86400 |
PUBLIC_HOSTNAME | tiles.customer.com |
Step 4 — Verify
curl -v https://tiles.customer.com/protomaps-2026-04/0/0/0.mvtCost
Per the Pinball Map blog (Nov 2024), 111 GB on R2 serving 50–60k map loads per month cost $1.67/month in storage — approximately 1/10 of Cloudflare's free bandwidth tier.
2. AWS S3 + CloudFront
Step 1 — S3 bucket policy
Create an S3 bucket and apply a bucket policy that allows s3:GetObject from CloudFront (Range GET is enabled by default on S3):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::spatial-tiles/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
}
}
]
}Step 2 — CloudFront distribution
- Origin: S3 bucket (Origin Access Control, not OAI)
- Cache policy:
Managed-CachingOptimized - Response-headers policy: add the following headers:
Access-Control-Allow-Origin: *(or your explicit origin)Access-Control-Allow-Headers: Range
Step 3 — Cache-Control headers (optional Lambda@Edge)
For immutable hashed PMTiles files, add a Lambda@Edge origin-response function:
// lambda/cache-headers/index.mjs
export const handler = async (event) => {
const response = event.Records[0].cf.response;
response.headers['cache-control'] = [{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
}];
return response;
};Step 4 — Verify Range GET
curl -H 'Range: bytes=0-127' -v \
https://d1234example.cloudfront.net/spatial/building-42.pmtilesThe response should return HTTP/2 206 Partial Content with a 128-byte body.
3. Self-hosted pmtiles serve
For on-premises or air-gapped deployments, use the official pmtiles CLI server:
pmtiles serve ./tiles --port 8080 --cors '*' --cache-size 256nginx reverse proxy with disk cache
Run pmtiles serve behind nginx for persistent disk caching:
# /etc/nginx/conf.d/pmtiles.conf
proxy_cache_path /var/cache/nginx
levels=1:2
keys_zone=pmtiles:100m
max_size=10g
inactive=7d
use_temp_path=off;
server {
listen 443 ssl http2;
server_name tiles.internal.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_cache pmtiles;
proxy_cache_valid 200 206 7d;
proxy_cache_use_stale error timeout updating;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Headers Range;
add_header Cache-Control "public, max-age=604800";
add_header X-Cache-Status $upstream_cache_status;
}
}Docker
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y wget && \
wget -O /usr/local/bin/pmtiles \
https://github.com/protomaps/go-pmtiles/releases/latest/download/go-pmtiles_linux_amd64 && \
chmod +x /usr/local/bin/pmtiles
COPY tiles/ /tiles/
EXPOSE 8080
CMD ["pmtiles", "serve", "/tiles", "--port", "8080", "--cors", "*"]4. Generating PMTiles
Requires Tippecanoe ≥ 2.17 for direct PMTiles output. See protomaps.com/pmtiles/create.
Install Tippecanoe
# macOS
brew install tippecanoe
# Ubuntu / Debian
sudo apt-get install tippecanoeFrom GeoJSON
tippecanoe \
-zg \
--projection=EPSG:4326 \
-o building-42.pmtiles \
-l units \
--drop-densest-as-needed \
-f \
indoor.geojsonFrom IMDF feature collections
Convert each IMDF feature collection to a separate Tippecanoe layer:
tippecanoe \
-L units:units.geojson \
-L levels:levels.geojson \
-L openings:openings.geojson \
-L amenities:amenities.geojson \
-L sections:sections.geojson \
-L venues:venues.geojson \
--projection=EPSG:4326 \
-zg \
-o building-42.pmtiles \
-fMerge multiple buildings into a single archive
tile-join \
-o all-buildings.pmtiles \
building-1.pmtiles \
building-2.pmtiles \
building-3.pmtilesInspect the result
pmtiles show building-42.pmtiles
pmtiles verify building-42.pmtiles5. Secured Tile Access (Signed URLs)
Avoid passing credentials directly to SDK props — they appear in React DevTools and may be captured by error-reporting SDKs (Sentry, Datadog). Use short-lived signed URLs instead; the client only ever sees a time-limited URL, not the underlying credentials.
v2.0.0 breaking change:
usernameandpasswordfields have been removed fromPMTilesHostingProfile. Pass a pre-signed URL viapublicUrlinstead.
Cloudflare Worker signer
// workers/tile-signer.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const tilePath = url.searchParams.get('path');
if (!tilePath) return new Response('Missing path', { status: 400 });
const upstream = new URL(`https://${env.TILE_BUCKET_DOMAIN}${tilePath}`);
const expiry = Math.floor(Date.now() / 1000) + 3600; // 1 h
upstream.searchParams.set('exp', String(expiry));
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(env.SIGNING_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign(
'HMAC', key,
new TextEncoder().encode(upstream.pathname + upstream.search),
);
upstream.searchParams.set('sig', btoa(String.fromCharCode(...new Uint8Array(sig))));
return new Response(upstream.toString(), { headers: { 'Content-Type': 'text/plain' } });
},
};AWS Lambda@Edge signer
// lambda/tile-signer/index.ts (Node 20, arm64)
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: process.env.AWS_REGION! });
export const handler = async (event: AWSLambda.APIGatewayEvent) => {
const key = event.queryStringParameters?.key;
if (!key) return { statusCode: 400, body: 'Missing key' };
const signedUrl = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: process.env.TILE_BUCKET!, Key: key }),
{ expiresIn: 3600 },
);
return { statusCode: 200, headers: { 'Content-Type': 'text/plain' }, body: signedUrl };
};Client usage
async function fetchSignedTileUrl(path: string): Promise<string> {
const res = await fetch(`/api/tile-signer?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error('Failed to get signed tile URL');
return res.text();
}
const profile: PMTilesHostingProfile = {
provider: 'self-hosted',
publicUrl: await fetchSignedTileUrl('/tiles/building.pmtiles'),
};Validate at startup
import { validateHostingProfile } from '@bloomsparkagency/pmtiles';
validateHostingProfile(profile);Migration checklist (v2.0.0)
- [ ] Remove
username/passwordfromPMTilesHostingProfile. - [ ] Add a server-side endpoint that generates short-lived signed URLs.
- [ ] Pass the signed URL as
publicUrlinstead. - [ ] Call
validateHostingProfile(profile)at startup. - [ ] Rotate any credentials that were previously used in client props.
Using PMTiles in the SDK
import { SpatialMap, BasemapPMTilesSource, BuildingPMTilesSource } from '@bloomsparkagency/core';
function App() {
return (
<SpatialMap initialViewState={{ longitude: -122.4, latitude: 37.8, zoom: 14 }}>
{/* Global basemap */}
<BasemapPMTilesSource url="pmtiles://https://tiles.customer.com/basemap.pmtiles" />
{/* Per-building archive */}
<BuildingPMTilesSource
buildingId="building-42"
url="pmtiles://https://tiles.customer.com/building-42.pmtiles"
/>
</SpatialMap>
);
}The pmtiles:// prefix is registered once by <SpatialProvider> at app root via a module-level singleton — never call addProtocol in render paths.
