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.
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.
What Self-Hosting Buys and Costs
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
@2xsheet 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,.pngand 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.
Related
- Base Layer Selection & Switching — the parent guide covering registries and switching
- Serving PMTiles from Object Storage Without a Tile Server — how the archive this style references is served
- Switching Between OpenStreetMap and Mapbox Basemaps at Runtime — swapping between a hosted and a self-hosted style
- Best Base Map Providers for High-Contrast Geo-Dashboards — what to change once the style is yours to edit