What you get#
The trick is PMTiles: a single-file archive format for map tiles. One .pmtiles file replaces a directory tree of millions of individual tile images, and any static host that supports HTTP range requests can serve it. Both the vector tiles and the terrain data above are PMTiles archives served from OVHcloud Object Storage.
Two projects provide the data:
- Protomaps publishes the full OpenStreetMap dataset as daily PMTiles builds.
- Mapterhorn provides free terrain elevation tiles, also as PMTiles, for hillshading and 3D terrain rendering.
Instead of hitting a commercial API for each tile request, you download the data once and serve it yourself. The rest of this post shows how.
Getting the tile data#
Vector tiles from Protomaps#
Protomaps publishes daily planet builds at maps.protomaps.com/builds. The full planet file covers zoom levels 0–15 and weighs roughly 120 GB. You probably don’t need all of it.
The pmtiles CLI lets you extract a regional subset:
# Download from https://github.com/protomaps/go-pmtiles/releases
# Extract a bounding box (e.g., Switzerland)
pmtiles extract planet.pmtiles switzerland.pmtiles \
--bbox=5.9,45.8,10.5,47.8
# Or use a GeoJSON boundary for a precise shape
pmtiles extract planet.pmtiles region.pmtiles \
--region=boundary.geojsonEach additional zoom level roughly doubles the file size, so use --maxzoom to limit what you extract. Upload the result to any static host or object storage - S3, R2, GCS, even a plain nginx server - and you have a vector tile source.
Terrain tiles from Mapterhorn#
Mapterhorn provides open terrain elevation data, so you can add 3D terrain without depending on a commercial provider. Two options:
- TileJSON endpoint (
https://tiles.mapterhorn.com/tilejson.json) : standard resolution, no setup needed. Tiles are served from Mapterhorn’s infrastructure, so this isn’t fully self-hosted, but it works out of the box. - PMTiles archives at download.mapterhorn.com : high-resolution tiles up to zoom 14+, split into regional archives. Download these to your own storage for a fully self-hosted setup. This requires a custom protocol handler (covered below).
To get started, the TileJSON endpoint is all you need. The PMTiles approach gives you both higher resolution and full control over hosting. Just like with the vector tiles, you can use pmtiles extract to cut a regional subset and avoid downloading the full planet.
Connecting MapLibre to Protomaps tiles#
Besides maplibre-gl, you need two additional packages: pmtiles for loading tiles from PMTiles archives, and @protomaps/basemaps for the layer styling.
npm install pmtiles @protomaps/basemapsRegistering the PMTiles protocol#
MapLibre doesn’t know how to handle pmtiles:// URLs out of the box. You register a protocol handler that translates range-request fetches into tile responses:
import maplibregl from 'maplibre-gl';
import { Protocol } from 'pmtiles';
const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);In React, register and clean up in a useEffect:
useEffect(() => {
const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
return () => {
maplibregl.removeProtocol('pmtiles');
};
}, []);Creating the map#
If you’ve used Mapbox before: you can’t reuse Mapbox styles here. Protomaps tiles have a different schema (different layer names, feature properties, and classification values). Instead, the layers() function from @protomaps/basemaps generates the full set of style layers mapped to the Protomaps schema.
A minimal Protomaps map looks like this:
import { layers, namedFlavor } from '@protomaps/basemaps';
const map = new maplibregl.Map({
container: 'map',
style: {
version: 8,
glyphs:
'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
sprite:
'https://protomaps.github.io/basemaps-assets/sprites/v4/light',
sources: {
protomaps: {
type: 'vector',
url: 'pmtiles://https://your-storage.example.com/your-tiles.pmtiles',
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
},
},
layers: layers('protomaps', namedFlavor('light'), { lang: 'en' }),
},
center: [8.55, 47.37],
zoom: 12,
});The three arguments to layers():
- Source name: must match the key in
sources(here"protomaps") - Flavor: a color scheme object from
namedFlavor(). Built-in options arelight,dark,white,grayscale, andblack. - Options:
langfor label language,labelsOnlyfor overlay use
Adding 3D terrain with Mapterhorn#
Flat maps work for navigation, but terrain makes elevation differences immediately visible - especially in mountainous regions. Mapterhorn provides free raster-dem elevation tiles that MapLibre can use for both hillshading and 3D terrain rendering.
Terrain sources and hillshading#
For 3D terrain, include Mapterhorn’s raster-dem sources in the style. Here I’m pointing both terrain and hillshade at a self-hosted planet.pmtiles extract via the pmtiles:// protocol.
import { Protocol } from 'pmtiles';
import { layers, namedFlavor } from '@protomaps/basemaps';
const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
const map = new maplibregl.Map({
container: 'map',
pitch: 60,
center: [8.0587, 46.4768],
zoom: 12,
style: {
version: 8,
glyphs:
'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
sprite:
'https://protomaps.github.io/basemaps-assets/sprites/v4/light',
sources: {
protomaps: {
type: 'vector',
url: 'pmtiles://https://your-storage.example.com/your-tiles.pmtiles',
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
},
terrainSource: {
type: 'raster-dem',
tiles: ['pmtiles://https://your-storage.example.com/terrain.pmtiles/{z}/{x}/{y}.webp'],
encoding: 'terrarium',
tileSize: 512,
attribution: '<a href="https://mapterhorn.com/attribution">© Mapterhorn</a>',
},
hillshadeSource: {
type: 'raster-dem',
tiles: ['pmtiles://https://your-storage.example.com/terrain.pmtiles/{z}/{x}/{y}.webp'],
encoding: 'terrarium',
tileSize: 512,
},
},
layers: [
...layers('protomaps', namedFlavor('light'), { lang: 'en' }),
{
id: 'hills',
type: 'hillshade',
source: 'hillshadeSource',
paint: {
'hillshade-shadow-color': '#473B24',
'hillshade-exaggeration': 0.3, // toned down to avoid overpowering the vector style
},
},
],
terrain: {
source: 'terrainSource',
exaggeration: 1,
},
sky: {},
},
});One gotcha: MapLibre requires distinct source references for the terrain property (the 3D mesh) and the hillshade layer (the shading overlay), so both point at the same terrain file.
The Mapterhorn protocol for high-resolution terrain#
The example above uses a single planet.pmtiles file, which covers zoom levels 0–12. That’s enough for most views, but terrain gets noticeably blocky when you zoom in closer. Mapterhorn distributes additional high-resolution archives at download.mapterhorn.com that go beyond zoom 12, split into regional files named by their zoom-6 tile coordinates.
Since the data is spread across multiple files, you can’t point a single pmtiles:// source at all of them. Instead, you register a custom mapterhorn:// protocol that routes each tile request to the correct archive based on the zoom level:
import * as pmtiles from 'pmtiles';
const pmtilesProtocol = new pmtiles.Protocol({
metadata: true,
errorOnMissingTile: true,
});
maplibregl.addProtocol('mapterhorn', async (params, abortController) => {
const [z, x, y] = params.url.replace('mapterhorn://', '').split('/').map(Number);
const name = z <= 12 ? 'planet' : `6-${x >> (z - 6)}-${y >> (z - 6)}`;
const url = `pmtiles://https://download.mapterhorn.com/${name}.pmtiles/${z}/${x}/${y}.webp`;
const response = await pmtilesProtocol.tile({ ...params, url }, abortController);
if (response['data'] === null) throw new Error(`Tile z=${z} x=${x} y=${y} not found.`);
return response;
});Three things to note about this protocol:
- It creates its own
pmtiles.Protocolinstance and callspmtilesProtocol.tile()directly — you do not register it withaddProtocol('pmtiles', ...). - If you’re also using PMTiles for vector tiles, register that
pmtiles://protocol separately as shown earlier. Two protocol instances, two registrations, one for each URL scheme. - To use it, swap the
pmtiles://terrain source URLs in the example above formapterhorn://{z}/{x}/{y}with terrarium encoding and 512px tile size.
Going further#
Once the base map and terrain are working, you can layer more data on top. All of the following are live on map.pascalspoerri.ch:
- 3D buildings - extrude the
buildingssource layer usingfill-extrusionwith itsheightandmin_heightproperties - Runtime style switching - rebuild the style with a different
namedFlavor()and callmap.setStyle() - Self-hosted satellite imagery - the same PMTiles approach works for raster data. I converted the ESA WorldCover land cover dataset into a PMTiles archive and serve it as an overlay. I built geotiff2pmtiles for this — it handles multi-band satellite data, categorical rasters, and scales to planet-sized datasets.
The MapLibre examples cover the general techniques for buildings and style switching.
What it costs#
The full Mapterhorn high-resolution terrain dataset is 4.6 TB. Hosting everything - vector tiles, terrain, and the web app - on OVHcloud Object Storage runs me about €40/month. OVH currently doesn’t charge for egress, so that’s pure storage cost. If you skip the high-resolution terrain tiles and stick with standard resolution, you’re looking at €5–10/month.
To be honest, for a low-traffic personal site like this one, self-hosting isn’t cheaper than a commercial provider. Mapbox’s free tier would cover it just fine, with higher resolution data out of the box. The real reason I self-host is control: no API keys, no usage limits to worry about, no vendor dependency. And the architecture scales - if traffic grows, you put a CDN in front of object storage and the setup stays the same.
