Self-hosting map tiles: TileServer GL, PMTiles, or hosted
A vector web map needs four things: the tiles, a style that says how to draw them, fonts for the labels, and icons. Self-hosting means serving all four yourself, so the map keeps working with no account, no per-view bill, and no other company's limits. This guide gets the tiles from the Protomaps basemap, then serves them three ways, each drawn with MapLibre GL JS.
It was tested with the pmtiles command-line tool, TileServer GL 5.6, MapLibre GL JS 6.15, PMTiles 4.5, Protomaps basemaps 5.7, and the Protomaps build of October 22, 2026, in Chromium.
Before you start
You need:
- The pmtiles command-line tool. Download the file for your system from its releases, unzip it, and put
pmtilesin a folder on yourPATH. Runpmtiles versionto check it works. - Node.js, for
npx serve, a small web server for trying the map on your computer. - Docker, only for TileServer GL in the third setup.
Get the tiles
Protomaps publishes a daily build of the whole planet from OpenStreetMap, as one PMTiles file. The October 22, 2026 build was 139 GB, with zoom levels 0 to 15. Builds are kept for a week.
Most maps need only an area. The pmtiles tool cuts one out of the planet file over the network, without downloading the rest. Make a folder for the map, and in it run this to take central Portland:
pmtiles extract https://build.protomaps.com/20261022.pmtiles portland.pmtiles \
--bbox=-122.84,45.43,-122.47,45.65
That takes about 10 seconds and makes a 35 MB file. The box is west,south,east,north. Change the date to a recent build's, and add --maxzoom to leave out the most detailed zoom levels and make the file smaller.
The tiles are built from OpenStreetMap, so the map has to credit it. The styles below do.
1. PMTiles on static storage
A PMTiles file can be served as it is, with no tile server. MapLibre's PMTiles plugin reads each tile straight out of the file with HTTP range requests. Any static host or object storage that supports range requests works, such as a CDN, Amazon S3, or Cloudflare R2.
In the folder with portland.pmtiles, make a file called index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Self-hosted map</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
</head>
<body style="margin: 0">
<div id="map" style="height: 100vh"></div>
<script type="module">
import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";
import { Protocol } from "https://cdn.jsdelivr.net/npm/pmtiles@4.5.0/+esm";
import { layers, namedFlavor } from "https://cdn.jsdelivr.net/npm/@protomaps/basemaps@5.7.2/+esm";
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
new maplibregl.Map({
container: "map",
center: [-122.6765, 45.5231],
zoom: 13,
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://${location.origin}/portland.pmtiles`,
attribution: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
},
},
layers: layers("protomaps", namedFlavor("light"), { lang: "en" }),
},
});
</script>
</body>
</html>
Here's what each part does:
- The
linkloads MapLibre's stylesheet, and themapelement is where the map is drawn. It needs a height; this one fills the window. type="module"makes the script a JavaScript module, so it canimportlibraries. The imports load MapLibre, the PMTiles plugin, and the Protomaps styles from jsDelivr, which serves any npm package.+esmasks for each as a module, and the version in the URL keeps the page from changing when a new version comes out.addProtocollets MapLibre read sources whose URL starts withpmtiles://, through the plugin.- The style is written out in the page, rather than loaded from a URL. Its source points at
portland.pmtileson the same site as the page, and credits Protomaps and OpenStreetMap. centeris[longitude, latitude], and zoom 13 shows most of the city.
Serve the folder with npx serve, and open http://localhost:3000 to see a map of Portland. The file has to come from a server that answers range requests, which serve does. Python's python3 -m http.server doesn't, so with it the map stays empty and the console says the server doesn't support "HTTP Byte Serving." Opened straight from a file, the map stays blank with Worker failed to load, since browsers don't let such a page start MapLibre's web worker.
layers builds the style's layers for the Protomaps tiles. Its flavors are light, dark, white, grayscale, and black, and lang picks the language of the labels. The fonts and icons above still come from Protomaps' GitHub site. To host those too, copy them from the basemaps-assets repository to your server and change the two URLs.
To put the map online, upload index.html and portland.pmtiles together to any static host that answers range requests. If the file is on another domain than the page, its host has to allow your site with CORS, including the Range header.
This is the simplest setup to run: one file, no server process, and a CDN can cache it. The cost is that it works only with clients that read PMTiles, such as MapLibre GL JS and OpenLayers with a plugin, not with apps that expect a {z}/{x}/{y} tile URL.
2. pmtiles serve
For clients that need {z}/{x}/{y} URLs, the same tool runs a small tile server over a folder of PMTiles files:
pmtiles serve . --port=8080 --cors=* --public-url=http://localhost:8080
Each file gets a tile URL and a TileJSON file named after it. For portland.pmtiles, they're http://localhost:8080/portland/{z}/{x}/{y}.mvt and http://localhost:8080/portland.json. --public-url is the address clients reach it at, which goes into the TileJSON. Behind a domain, set it to that.
Run it in the folder with portland.pmtiles, in a second terminal next to npx serve. The page then no longer needs the PMTiles plugin: drop its import and the two protocol lines, and point the source at the TileJSON. Here's the whole page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Self-hosted map</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
</head>
<body style="margin: 0">
<div id="map" style="height: 100vh"></div>
<script type="module">
import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";
import { layers, namedFlavor } from "https://cdn.jsdelivr.net/npm/@protomaps/basemaps@5.7.2/+esm";
new maplibregl.Map({
container: "map",
center: [-122.6765, 45.5231],
zoom: 13,
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: "http://localhost:8080/portland.json",
attribution: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
},
},
layers: layers("protomaps", namedFlavor("light"), { lang: "en" }),
},
});
</script>
</body>
</html>
Reload http://localhost:3000 and the same map draws from pmtiles serve.
pmtiles serve can also read files from S3 or other buckets with --bucket, so the file doesn't need to be on the server's disk.
3. TileServer GL
TileServer GL is a longer-standing tile server that reads MBTiles and PMTiles files. Its full version can also draw tiles as images on the server, for clients that can't draw vector tiles, such as plain Leaflet, or for static map images. In the folder with the file:
docker run --rm -it -v $(pwd):/data -p 8080:8080 maptiler/tileserver-gl:v5.6.0 --file portland.pmtiles
It lists what it serves at http://localhost:8080. For a file given with --file, the TileJSON is at http://localhost:8080/data/pmtiles.json. Put that in the source's url in the second setup's page, in place of http://localhost:8080/portland.json. Stop pmtiles serve first, since both use port 8080.
TileServer GL's built-in styles and its image rendering expect tiles in the OpenMapTiles schema, which Planetiler can build from an OpenStreetMap extract. With Protomaps tiles, it says PMTiles not in "openmaptiles" format. Serving raw data only, and serves the vector tiles for MapLibre to draw, as above. To draw images on the server, give it a config file with a style for your tiles.
Which to choose
| PMTiles on static storage | pmtiles serve | TileServer GL | |
|---|---|---|---|
| Server to run | None | A small Go program | A Node.js server, in Docker |
| Tile URLs | One file, read by the PMTiles plugin | {z}/{x}/{y} and TileJSON |
{z}/{x}/{y} and TileJSON |
| Images drawn on the server | No | No | Yes, in the full version, with a style |
| Best for | Web maps with MapLibre | Apps and SDKs that need tile URLs | Image tiles and static maps |
For most web maps, PMTiles on static storage is the least to run. Keeping it current means downloading a new build and swapping the file. OpenStreetMap changes daily, but a map usually only needs refreshing every month or so.
Hosted tiles instead
Self-hosting the whole planet means storing a file of about 140 GB, paying for the bandwidth of every map view, and refreshing it. Mapricot's map tiles are the same Protomaps basemap, hosted, for the whole planet, refreshed about monthly. They're free, with no key and no per-view price, and the styles include the fonts and icons. In the first setup's page, drop the PMTiles and Protomaps imports and the two protocol lines, and replace the whole style object with its URL:
new maplibregl.Map({
container: "map",
center: [-122.6765, 45.5231],
zoom: 13,
style: "https://tiles.mapricot.com/styles/light.json",
});
Map tiles has the whole page.
Because the tiles use the same schema, a style written for Protomaps tiles also draws Mapricot's. You can start on hosted tiles and move to your own file later without restyling the map.
Questions
What's the difference between PMTiles and MBTiles? Both hold a whole tileset in one file. MBTiles is an SQLite database, so a server has to read it and hand out tiles. PMTiles is laid out so a client can fetch each tile from the file with an HTTP range request, which lets it sit on static storage. pmtiles convert turns an MBTiles file into PMTiles.
Can I host raster tiles this way? Yes. PMTiles and both servers hold image tiles as well as vector ones. They're larger than vector tiles, and can't be restyled in the browser.
Are Protomaps tiles free to use? Yes. The basemap is published as a Produced Work of OpenStreetMap data under the ODbL, so it's free to use with the OpenStreetMap credit shown on the map.
Can I use these tiles with Leaflet? Yes, with a plugin that draws vector tiles. Leaflet geocoding and routing uses the MapLibre GL Leaflet plugin, which takes a MapLibre style like the ones above.