Protomaps: self-hosted vector maps from a single file
I was looking into self-hosting maps for Landbound and dreading the complexity of running a tile server. Then I found Protomaps.
The usual path for self-hosted vector maps looks something like this: set up PostgreSQL with PostGIS, import OpenStreetMap data with osm2pgsql, run a tile server like tileserver-gl or Martin, configure caching, worry about scaling. It is a lot of moving parts for a side project that just needs a map.
Protomaps takes a completely different approach. One file. No server-side processing. Just static hosting and HTTP range requests.
What Protomaps actually is
Protomaps is an open-source project by Brandon Liu that packages map tiles into a single archive file called PMTiles. The format maps tile coordinates (zoom level, x, y) to byte offsets within the file. When a client needs a specific tile, it makes an HTTP range request for just those bytes. The server does not need to know anything about tiles. It just serves bytes from a file.
Think of it like a ZIP archive designed for random access. The directory at the beginning of the file tells the client exactly where each tile lives. No extraction, no server-side routing, no tile generation at request time.
The current version of the spec is PMTiles v2. It keeps things deliberately simple: a header, a root directory of tile entries, metadata, and then the tile data itself. The directory maps z/x/y coordinates to byte ranges. That is the whole format.
Generating a PMTiles file
The workflow starts with OpenStreetMap data. Grab a regional extract from Geofabrik in PBF format:
wget https://download.geofabrik.de/europe/germany/bayern-latest.osm.pbfOSM PBF files are not directly usable as tiles. You need to process them into vector tiles first. Tilemaker can go straight from PBF to MBTiles:
tilemaker --input bayern-latest.osm.pbf --output bayern.mbtilesIf you already have GeoJSON data, tippecanoe is the standard tool for building tilesets:
tippecanoe -zg --no-tile-compression -o bayern.mbtiles input.geojsonEither way, you end up with an MBTiles file. Right now, tippecanoe does not output PMTiles directly, so the last step is converting with the go-pmtiles CLI:
pmtiles convert bayern.mbtiles bayern.pmtilesThe go-pmtiles tool is a single Go binary you can download from GitHub. The conversion is fast and handles tile deduplication automatically, which matters because map tilesets contain a lot of duplicate ocean and empty tiles.
For a region like Bavaria, you are looking at a few hundred megabytes. A full country like Germany runs into the low gigabytes. The entire planet at all zoom levels is around 100GB or more. Pick the scope that matches your use case.
Serving it
Here is the beautiful part: any static file host that supports HTTP range requests can serve a PMTiles archive. That covers most of them.
Cloudflare R2 just hit general availability a few days ago and it is a natural fit. No egress fees, S3-compatible API, and it supports range requests out of the box. Upload your PMTiles file to an R2 bucket, make it publicly accessible, and you have a tile server with zero maintenance.
# Upload to R2 using the S3-compatible API
aws s3 cp bayern.pmtiles s3://my-tiles-bucket/bayern.pmtiles \
--endpoint-url https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.comS3 works too, though the egress costs add up with map tiles since users fetch a lot of them during a single session. Nginx with sendfile on a VPS is another option if you want to keep everything on your own hardware.
The key requirement is HTTP range request support. The client will send requests like Range: bytes=1024-2048 and the server needs to respond with just those bytes. Most modern web servers and object storage services handle this without any configuration.
MapLibre integration
MapLibre GL JS is the open-source fork of Mapbox GL JS that the community started maintaining after Mapbox changed their license in late 2020. It gives you vector tile rendering, smooth zooming, and dynamic styling without the vendor lock-in. I will probably write a deeper comparison of mapping libraries at some point, but for now MapLibre is what I am using.
The pmtiles JavaScript library acts as the glue between a PMTiles archive and MapLibre. It intercepts tile requests, translates them into range requests against your PMTiles file, and feeds the results back to MapLibre.
Here is a minimal example:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl/dist/maplibre-gl.css" />
<script src="https://unpkg.com/maplibre-gl/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/pmtiles@2/dist/index.js"></script>
<style>
#map { width: 100%; height: 100vh; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
openmaptiles: {
type: "vector",
url: "pmtiles://https://your-bucket.example.com/bayern.pmtiles"
}
},
layers: [
{
id: "water",
type: "fill",
source: "openmaptiles",
"source-layer": "water",
paint: { "fill-color": "#a0c4ff" }
},
{
id: "roads",
type: "line",
source: "openmaptiles",
"source-layer": "transportation",
paint: { "line-color": "#888", "line-width": 1 }
}
]
}
});
</script>
</body>
</html>The pmtiles:// protocol prefix tells the library to fetch tiles via range requests from the PMTiles archive instead of a traditional tile server endpoint. You register the protocol once and then reference it in your MapLibre style sources.
Limitations worth knowing
PMTiles is not magic. There are real tradeoffs.
File sizes scale with area. A city-level extract is manageable. A continent-level extract gets large fast. You need to think about what zoom levels you actually need and scope your extracts accordingly.
No real-time updates. The archive is static. If OpenStreetMap data changes, you need to regenerate and re-upload the file. For most use cases this is fine since map data does not change that frequently. But if you need live edits reflected immediately, this is not the right approach.
Browser caching is limited. Browsers do not cache range request responses as aggressively as full file downloads. Users visiting the same area repeatedly may re-fetch tiles they have already seen. This is a browser limitation, not a PMTiles issue, but it affects the user experience.
Initial load latency. The first tile request requires fetching the PMTiles directory to know where tiles live in the file. With v2, that initial request is around 512KB. It is noticeable on slower connections. Not a dealbreaker, but worth being aware of.
Where this fits for Landbound
For a trip planning app focused on outdoor adventures, the map is the core of the entire experience. I need vector tiles for dynamic styling, smooth zooming, and the ability to overlay trail data. But I do not need planet-scale coverage. Landbound users are planning trips in specific regions, so scoped extracts are perfect.
I am still early in experimenting with this. There is more to figure out around tile styling, keeping extracts updated, and whether PMTiles performs well enough for production use. I will write more as I learn. But the core idea of serving a full vector tileset from a single static file, with no server infrastructure beyond file hosting, is compelling enough that I wanted to document what I have found so far.
Sources
Related posts
Self-hosting with Coolify: a PaaS on your own server
How Coolify turns your VPS into a Heroku-like platform for deploying apps, databases, and services with a clean web UI.
Backup strategies for self-hosted data
The 3-2-1 backup rule applied to self-hosted services, with practical tools and patterns I use to protect my data.
Self-hosting a media server with Jellyfin
Setting up Jellyfin to stream movies, music, and photos across all my devices without a Plex subscription.
Enjoying the blog? Subscribe via RSS to get new posts in your reader.
Subscribe via RSS