Skip to content

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.


Cloudflare R2 + the Protomaps Cloudflare Worker gives the lowest cost and lowest latency for global CDN tile delivery.

Step 1 — Create an R2 bucket

sh
wrangler r2 bucket create spatial-tiles

Step 2 — Upload PMTiles archives

The Cloudflare web UI is limited to 300 MB. For larger archives, use rclone:

sh
rclone copy ./building-42.pmtiles spatial-r2:spatial-tiles \
  --s3-upload-cutoff=200M \
  --s3-chunk-size=200M

For the global basemap (typically >1 GB):

sh
rclone copy ./protomaps-2026-04.pmtiles spatial-r2:spatial-tiles \
  --s3-upload-cutoff=200M \
  --s3-chunk-size=200M

Step 3 — Deploy the Protomaps Cloudflare Worker

Clone and deploy the Protomaps Cloudflare Worker:

sh
git clone https://github.com/protomaps/PMTiles
cd PMTiles/serverless/cloudflare
npm install
wrangler deploy

Set the following Worker variables in wrangler.toml or the Cloudflare dashboard:

VariableExample value
ALLOWED_ORIGINShttps://app.customer.com,https://localhost:3000
PMTILES_PATH{name}.pmtiles
CACHE_CONTROLpublic, max-age=86400
PUBLIC_HOSTNAMEtiles.customer.com

Step 4 — Verify

sh
curl -v https://tiles.customer.com/protomaps-2026-04/0/0/0.mvt

Cost

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):

json
{
  "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:

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

sh
curl -H 'Range: bytes=0-127' -v \
  https://d1234example.cloudfront.net/spatial/building-42.pmtiles

The 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:

sh
pmtiles serve ./tiles --port 8080 --cors '*' --cache-size 256

nginx reverse proxy with disk cache

Run pmtiles serve behind nginx for persistent disk caching:

nginx
# /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

dockerfile
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

sh
# macOS
brew install tippecanoe

# Ubuntu / Debian
sudo apt-get install tippecanoe

From GeoJSON

sh
tippecanoe \
  -zg \
  --projection=EPSG:4326 \
  -o building-42.pmtiles \
  -l units \
  --drop-densest-as-needed \
  -f \
  indoor.geojson

From IMDF feature collections

Convert each IMDF feature collection to a separate Tippecanoe layer:

sh
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 \
  -f

Merge multiple buildings into a single archive

sh
tile-join \
  -o all-buildings.pmtiles \
  building-1.pmtiles \
  building-2.pmtiles \
  building-3.pmtiles

Inspect the result

sh
pmtiles show building-42.pmtiles
pmtiles verify building-42.pmtiles

5. 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: username and password fields have been removed from PMTilesHostingProfile. Pass a pre-signed URL via publicUrl instead.

Cloudflare Worker signer

typescript
// 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

typescript
// 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

typescript
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

typescript
import { validateHostingProfile } from '@bloomsparkagency/pmtiles';
validateHostingProfile(profile);

Migration checklist (v2.0.0)

  • [ ] Remove username / password from PMTilesHostingProfile.
  • [ ] Add a server-side endpoint that generates short-lived signed URLs.
  • [ ] Pass the signed URL as publicUrl instead.
  • [ ] Call validateHostingProfile(profile) at startup.
  • [ ] Rotate any credentials that were previously used in client props.

Using PMTiles in the SDK

typescript
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.


Reference

Released under commercial licensing.