Self-Hosting a Basemap Style with MapLibre and PMTiles

Part of the Base Layer Selection & Switching guide.

Operative rule: a self-hosted basemap is four artifacts, not one — style, tiles, glyphs and sprites — and each has its own URL in the style document that must be rewritten to your own origin.

The Four Artifacts

A style document is a manifest, not a map. It names the sources to fetch tiles from, the glyphs template used to request font ranges for labels, and the sprite base URL for icons and fill patterns. Downloading a style from a provider and hosting the JSON alone leaves three references pointing back at that provider — which is why a “self-hosted” map so often renders geometry with no labels and no icons.

Each artifact fails differently and none of them raise an obvious error, so knowing the four symptoms saves an afternoon.

Four artifacts and the symptom of each one missing The style document defines layers and paint; without it nothing renders. The tile archive supplies geometry; without it the map is empty but styled. Glyph ranges supply font bitmaps; without them labels vanish silently. The sprite sheet supplies icons and patterns; without it symbols disappear and pattern fills fall back to flat colour. Four URLs in one document — rewrite all four style.json layers, paint, sources missing → nothing renders at all, and the console says so tiles.pmtiles the geometry missing → an empty canvas in the style's background colour glyphs/{range}.pbf font bitmaps for labels missing → every label vanishes, silently — the most common symptom sprite.json + .png missing → icons disappear and pattern fills become flat colour

Production-Ready Implementation

The rewrite is mechanical and belongs in the build, so the deployed style can never reference an origin you do not control.

from __future__ import annotations

import json
from pathlib import Path
from urllib.parse import urlparse

SELF_ORIGIN = "https://maps.example.org"


def rewrite_style(style_path: Path, archive_name: str, out_path: Path) -> dict:
    """Point every external reference in a style document at our own origin."""
    style = json.loads(style_path.read_text(encoding="utf-8"))

    style["glyphs"] = f"{SELF_ORIGIN}/fonts/{{fontstack}}/{{range}}.pbf"
    style["sprite"] = f"{SELF_ORIGIN}/sprite/basemap"

    for name, source in style.get("sources", {}).items():
        if source.get("type") != "vector":
            continue
        source.pop("url", None)                 # drop any TileJSON indirection
        source["tiles"] = None                  # tiles come from the archive
        source["url"] = f"pmtiles://{SELF_ORIGIN}/tiles/{archive_name}"

    remaining = external_references(style)
    if remaining:
        raise ValueError(f"style still references external origins: {remaining}")

    out_path.write_text(json.dumps(style, indent=2), encoding="utf-8")
    return style


def external_references(style: dict) -> list[str]:
    """Every absolute URL in the style that is not our own origin."""
    found: list[str] = []

    def visit(node) -> None:
        if isinstance(node, dict):
            for value in node.values():
                visit(value)
        elif isinstance(node, list):
            for value in node:
                visit(value)
        elif isinstance(node, str) and "://" in node:
            host = urlparse(node.replace("pmtiles://", "https://")).netloc
            if host and host != urlparse(SELF_ORIGIN).netloc:
                found.append(node)

    visit(style)
    return sorted(set(found))

The external_references assertion is the part worth keeping permanently. It turns “we think we self-hosted it” into a build failure the first time someone adds a layer that quietly pulls a sprite from elsewhere, which is exactly how a self-hosted map acquires a third-party dependency again six months later.

Generating Glyphs and Sprites

Glyph ranges are pre-rendered bitmaps of a font, split into blocks of 256 code points, requested on demand by the renderer. Sprites are a packed image plus a JSON index describing where each icon sits within it, published at one and two times scale.

Where glyphs and sprites live once self-hosted Fonts are stored under a directory per fontstack, each holding range files covering 256 code points. Sprites are stored as a JSON index and a PNG, published at both one and two times scale so retina displays get the sharper sheet. Two directories, generated once, published with the site /fonts/{fontstack}/{range}.pbf one directory per named font stack 0-255.pbf, 256-511.pbf, … on demand only the ranges your labels use are fetched the stack name must match text-font in the style exactly /sprite/basemap{.json,.png} a packed PNG plus a JSON index published at 1× and @2x for retina the style names the base, without extension a missing @2x is only visible on high-density screens Both are static files with year-long cache lifetimes — they change only when the style's typography or iconography does.

What Self-Hosting Buys and Costs

The trade of owning your basemap Self-hosting removes a third-party runtime dependency, removes key management and rate limits, and allows unrestricted styling. It costs storage for the archive, a pipeline to regenerate it, and the responsibility for keeping the underlying data current. Owning the basemap moves work, it does not remove it gained no third-party runtime dependency no tokens, no rate limits, no attribution surprises unrestricted styling, including print and offline the map keeps working when a vendor changes terms paid for storage for the archive, glyphs and sprites a pipeline to regenerate from source data responsibility for keeping the basemap current and for the source data's own attribution terms Licence obligations do not disappear with the vendor — the underlying data still requires its credit line.

Verification Steps

  • Run the external-reference assertion in the build and confirm it returns an empty list.
  • Load the map with the network panel filtered to your origin only and confirm nothing else is requested.
  • Zoom to an area with labels in a non-Latin script and confirm the correct glyph range is fetched.
  • Check a retina display for sprite sharpness — a missing @2x sheet only shows there.
  • Confirm the attribution control still carries the source data’s required credit.

Common Errors & Fixes

Geometry renders but there are no labels

The glyphs template still points at the original provider. Rewrite it and confirm the font stack name in the style matches the directory you generated.

Icons are missing and fills are flat

The sprite base URL is wrong, or only the JSON was published. The base must resolve to four files: JSON and PNG at both scales.

The style loads over HTTP in development and fails in production

A mixed-content block. Every URL in the style must be HTTPS in production; generate them from one origin constant rather than editing by hand.

A layer suddenly references an external host again

Someone copied a layer from another style. The build assertion catches this the first time it happens, which is the reason to keep it permanently.

Keeping a Self-Hosted Basemap Current

Owning the basemap means owning its refresh cycle. Street data changes constantly: new roads open, addresses are added, boundaries are redrawn. A basemap generated once and never regenerated is correct on the day it ships and slowly diverges from reality, which is far less visible than a broken map and considerably more embarrassing when someone notices.

The refresh itself is the same pipeline that produced the archive in the first place, run on a schedule that matches how much the underlying data actually moves. For most operational dashboards a quarterly regeneration is ample; for anything tracking new development, monthly is closer. What matters is that the cadence is a decision rather than an accident, and that it is recorded somewhere a future maintainer will find it.

Two practices make that refresh safe. The first is to treat the regenerated archive exactly like any other build artifact: content-hashed name, published alongside the previous one, switched by editing the style’s source URL and deploying. If the new basemap has a problem — a rendering artefact, a missing region, a font that no longer resolves — the previous archive is still there and reverting is a one-line change.

The second is a visual comparison before the switch. Capture the same handful of viewpoints from the old and new basemaps at two or three zoom levels and look at them side by side. Automated checks will confirm that tiles exist and that the style parses; only a human glance catches the case where a landuse layer stopped rendering because an upstream schema renamed a field.

It is also worth deciding, in advance, what happens when the source data’s licence terms change. Self-hosting removes the vendor from the runtime path but not from the legal one: the attribution obligations of the underlying data travel with the tiles, and a basemap regenerated from a new source may carry different requirements. Recording the source and its terms next to the generation script means that question can be answered in a minute rather than reconstructed from memory.

Gotchas & Edge Cases

  • Font stack names in the style must match the generated directory names exactly, including spaces and case; a mismatch renders no labels and logs nothing useful.
  • Sprite base URLs are given without an extension, and the renderer appends .json, .png and the retina variants itself — publishing only two of the four files fails only on high-density screens.
  • A style that inherits "glyphs" from a template will silently point at whichever provider wrote the template; the build assertion is what catches this on the day it is introduced.
  • Non-Latin labels pull additional glyph ranges on demand, so a style that looks complete in one region can be missing fonts in another — test with an extent that exercises the scripts your audience actually reads.
  • Regenerating the archive changes tile content but not the style; if a source layer is renamed the style must be regenerated in the same run, or every layer referencing it renders empty.
  • Self-hosting removes the vendor from the request path but not the licence from the data — keep the attribution control populated from the source’s terms, not from the style’s origin.