← back

23 Aug 2026

Self hosting maps

PMTiles are delightfully boring

For an upcoming project, I’m going to need a world map in a web app to display location data. I’ve done that before, using mapbox as the tile server, but this time I want to serve the map data myself. A couple of years ago I stumbled across PMTiles, at the time a rather novel way to serve maps. I didn’t have a need for it then, so it just sat in the back of my mind as something to take a closer look at later. Now the time has come, so in this note I’m going to play around with PMTiles and see if they fit my needs.

Background

First, a short explainer: PMTiles is a file format crafted so that individual mapping tiles can be retrieved using HTTP Range requests. You can chuck such a file onto S3, or serve it from an HTTP server like Caddy, and a suitable JS library can immediately start retrieving the tiles it needs for the current map location and zoom level, all from that one file.

Compare that to a tiling server, which has map data on disk but needs a runtime component to serve it in the appropriate format. The advantage is clear. Mind you, that comes with some disadvantages. For example, PMTiles are read-only, so you can’t update part of a map in place. Protomaps has a good article about the tradeoffs if you want to read more.

Boring technology

For my usage, I’ll prepare a map file to my liking and serve it from my server with Caddy. If I want to include the latest OpenStreetMap changes, I just recreate the map and that’s it. It’s delightful. No additional moving parts to manage and operate. More technologies should be like this.

Creating PMTiles

With the musings out of the way, let’s get practical. First, grab the latest build from this page on Protomaps. It’s essentially a dump of all OpenStreetMap data for the whole planet. We don’t want to download it, just note the filename.

Next, using the PMTiles CLI, we can generate our own PMTiles file of the world, capped at a maximum zoom level. That limits how much detail you see when zooming in close, and in turn keeps the file much smaller.

./pmtiles extract https://build.protomaps.com/20260823.pmtiles world.pmtiles --maxzoom=4

This gives us a 6 MB file. Super small, but also not so detailed.

Next, let’s create a high-fidelity file for a single region. For that, we need a bounding box around the region we care about. To avoid juggling coordinates by hand, bbox finder lets you draw a box easily.

Then we can generate a full-fidelity extract of that region like so:

./pmtiles extract https://build.protomaps.com/20260823.pmtiles zurich.pmtiles --bbox=8.497925,47.339055,8.588905,47.395444

This results in a 6.3 MB file. Viewing it on pmtiles.io shows every detail in Zurich, but nothing outside the bounding box.

What’s really nice is that we can now combine the two files into one with the advantages of both. That only works if the files don’t overlap, though. So far, we have the whole world up to zoom level 4, but Zurich at every zoom level. To make it work, we need Zurich only from zoom level 5 and up:

./pmtiles extract zurich.pmtiles zurich-hi.pmtiles --minzoom=5

Resulting in a slightly smaller file at 5.8 MB. Now, the two files are non-overlapping and can be combined like so:

./pmtiles merge world.pmtiles zurich-hi.pmtiles combined.pmtiles

This results in a 12 MB file with high detail around Zurich and a low-fidelity basemap for the rest. Neat!

There’s one catch, depending on the renderer: in the plain world.pmtiles basemap, I can zoom past maxzoom and still see a low-fidelity map. With the combined map, however, zooming past maxzoom turns everything outside Zurich black. That’s because the combined file declares maxzoom: 15 in its header: the renderer expects zoom level 15 everywhere and paints black wherever that data is missing.

To fix this, configure the JS rendering lib with two sources at different zoom levels, both pointing to the same file.

Verdict

It’s truly as simple and easy as it says on the box. Consider me impressed. For my purposes, I might explore automatically deriving a set of high-fidelity bounding boxes from my location data and merging them into one ultimate file. But that may well be overengineering.