Self-Host OpenStreetMap Vector Tiles with PMTiles
The Map Bill That Never Arrives
Commercial map APIs feel free until the invoice shows up. Every pan, every zoom, every visitor is a metered request, and a successful page can turn into a four-digit monthly bill. The alternative sounds intimidating, self-hosting OpenStreetMap tiles, and it used to be: databases, tile servers, gigabytes of cache.
PMTiles changed that. Your whole basemap becomes one static file on object storage, read directly by the browser with HTTP range requests. No tile server, no database, no API keys. In this guide we set it up end to end: download an extract, upload it to Cloudflare R2 and wire it into MapLibre GL.
30-SECOND SUMMARY
PMTiles packs an entire tile pyramid into a single file that browsers read piece by piece with range requests. Protomaps publishes daily planet builds of OpenStreetMap; the pmtiles CLI can extract just your country or region from the remote build without downloading the planet. Host the file on Cloudflare R2 (free egress) with a small CORS policy, and MapLibre GL reads it through the pmtiles protocol or a tiny serverless worker. Realistic monthly cost: a few dollars, often zero.
What PMTiles Is and Why It Works
A classic tile server answers millions of small questions: give me tile z/x/y. PMTiles flips the model. All tiles are packed into one archive with an index, and the client asks for byte ranges of that one file. Object storage and CDNs are extremely good at serving byte ranges, so the entire serving stack becomes: one file, one bucket.
- No server to maintain: there is nothing to patch, restart or scale.
- Cheap by design: storage is pennies per gigabyte and range reads are fractions of a cent per million on R2.
- Portable: the same file works locally, on any S3-compatible storage, or behind a CDN.
Step 1: Get the Data
Protomaps builds the full OpenStreetMap planet into PMTiles every day at build.protomaps.com. The planet is around 120 GB, but you almost never need it. The pmtiles CLI extracts a region directly from the remote file:
# download the CLI from github.com/protomaps/go-pmtiles/releases
pmtiles extract https://build.protomaps.com/20260701.pmtiles turkiye.pmtiles \
--bbox=25.5,35.6,45.0,42.4 --download-threads=8
The bbox above covers Türkiye; swap in any bounding box or pass a GeoJSON boundary with the region flag. Only the bytes belonging to your region are downloaded. A country-sized extract typically lands in the low gigabytes; if size matters, add a maxzoom flag (zoom 14 halves the archive again and still looks sharp for most use cases).
Sanity-check the result before uploading:
pmtiles show turkiye.pmtiles
Step 2: Host It on Cloudflare R2
Any S3-compatible storage works. We like Cloudflare R2 because egress is free, which is exactly the cost profile a public map needs. Create a bucket, upload the archive (rclone handles multi-gigabyte multipart uploads well) and attach a custom domain.
rclone copyto turkiye.pmtiles r2:my-maps/v1/tiles/turkiye.pmtiles
Browsers reading PMTiles need a CORS policy that allows range requests and exposes ETag:
[{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["range", "if-match"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 3000
}]
Step 3: Read It from MapLibre GL
There are two serving patterns, and they are not mutually exclusive:
Pattern A: browser-direct with the pmtiles protocol
The pmtiles JavaScript package teaches MapLibre a pmtiles:// URL scheme. No infrastructure at all beyond the bucket:
import maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
protomaps: {
type: "vector",
url: "pmtiles://https://tiles.example.com/v1/tiles/turkiye.pmtiles",
attribution: "Protomaps © OpenStreetMap"
}
},
layers: [ /* your layers */ ]
}
});
Pattern B: a tiny worker that speaks z/x/y
The PMTiles repository ships a ready-made Cloudflare Worker that reads the archive from R2 and exposes classic tile URLs plus TileJSON. The win: consumers need zero extra code, any stock MapLibre (or other tile client) can use the URL, and hot tiles get edge-cached. This is the pattern we use for our own public styles.
{
"type": "vector",
"url": "https://tiles.hititmedya.com/planet.json"
}
What It Costs
~$0.05/mo
Storage for a 3 GB country extract on R2
$0
Egress on Cloudflare R2, at any traffic level
$0.36
Per million range reads after the free 10M/month
Compare that with per-request pricing of commercial map APIs and the difference at scale is one to two orders of magnitude. The real recurring cost is not money, it is the monthly data refresh, which is one extract command and one upload.
Attribution Is Not Optional
OpenStreetMap data is licensed under ODbL. Wherever the map is shown, you must display attribution to OpenStreetMap. In MapLibre, set the attribution field on the source and leave the attribution control on. Crediting Protomaps for the builds is good practice too.
Skip the setup, use our hosted styles
We run this exact stack for our public map gallery: PMTiles on R2, a worker for clean tile URLs and 12 branded styles ready to embed.
All tools are free, no API key required.
Frequently Asked Questions
01How big is a country extract?
It depends on how densely mapped the country is. Extracts at the full zoom range typically land between one and a few gigabytes for a mid-sized country. Dropping the max zoom from 15 to 14 roughly halves the size while staying sharp enough for most products.
02How often should I refresh the data?
Monthly is a good default for most sites. The refresh is mechanical: run the same extract against the newest daily build, verify it locally, overwrite the file in the bucket and purge the CDN cache. The whole process takes about fifteen minutes of attention.
03Do I need Cloudflare specifically?
No. Any S3-compatible object storage that supports range requests and CORS works: AWS S3, Backblaze B2, MinIO on your own server. R2 stands out because egress is free, which matters for a public map that anyone can embed.
04Can other people hotlink my tiles?
If you serve with open CORS, yes, and for a public gallery that can be the point. If it becomes a problem you can restrict allowed origins on the worker, add a rate limit rule at the CDN, or keep the bucket private and serve only through the worker.
Sources and References
Format details, the CLI and serving patterns, straight from the source.
The single-binary tool used for extracting, inspecting and serving PMTiles archives.
Object storage with free egress, custom domains and S3-compatible tooling.
The attribution requirements that apply to every map built on OSM data.