Documentation menu
Self-hosting
The point of MapMap is that you can run all of it yourself: the gateway, our routing engine, an optional geocoder, and the MCP server, from one Docker Compose file. This page is the orientation and a working first boot; the full production guides (sizing, backup, upgrades, hardening, air-gapped deployment) ship inside the source checkout.
Status, honestly: the hosted gateway at https://api.mapmap.ai is live; see
conventions. Self-hosting is for when you want the same
stack on your own infrastructure, under your own keys and data boundary.
What you need
- Docker Engine 24+ with Compose v2 (
docker compose version) - ~10 GB disk and 8 GB RAM for a UK-scale first tile build (serving needs less), plus ~1.5 GB once for shared global base-map sources (ocean water polygons, Natural Earth)
- Outbound internet on first boot only (OSM extract and global base-map source downloads); air-gapped installs are supported via pre-built territory packages; a territory package bundles pre-built routing tiles and map data for a region, so no OSM download is needed
Get the code
The repository is private: source access comes with a commercial relationship
or an evaluation. Email hello@mapmap.ai; you get
the full workspace, including the distro/ Compose stack and the production
operations guides. Everything below assumes you have a checkout.
The short version
cd distro
cp .env.example .env
# Admin token: protects /admin/*. Compose refuses to start without it.
sed -i '' "s/^SN_ADMIN_TOKEN=$/SN_ADMIN_TOKEN=$(openssl rand -hex 32)/" .env # macOS
# sed -i "s/^SN_ADMIN_TOKEN=$/SN_ADMIN_TOKEN=$(openssl rand -hex 32)/" .env # Linux
# Network-tag secret: keys the per-network signup budgets. Also required.
echo "SN_NETWORK_TAG_SECRET=$(openssl rand -hex 32)" >> .env
Pick a territory: for a bootstrap build straight from OSM (the simplest first
run), also set in .env:
VALHALLA_TILE_URLS=https://download.geofabrik.de/europe/united-kingdom-latest.osm.pbf
Any Geofabrik extract works. The production alternative is a pre-built territory package, which skips the build entirely. Then:
docker compose up -d --build
docker compose logs -f valhalla # first boot builds tiles: ~30–60 min for the UK
While tiles build, docker compose ps shows the gateway stuck in Created;
that is expected, not broken: the gateway waits for the routing engine's
healthcheck. You are up when this succeeds:
curl -fsS http://localhost:8080/health
Create an API key
Key management lives under /admin/*, authenticated with your admin token.
All three body fields are required; there are no defaults:
source .env
curl -fsS -X POST http://localhost:8080/admin/keys \
-H "Authorization: Bearer ${SN_ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"name": "fleet-ops-dev", "monthly_quota": 100000, "rate_per_min": 60}'
Returns 201 Created:
{
"id": "…",
"name": "fleet-ops-dev",
"key": "snk_…",
"created_at": "2026-07-15T09:30:00Z",
"monthly_quota": 100000,
"rate_per_min": 60
}
The key field is shown once: store it in your secret manager; only its
BLAKE3 hash is persisted. Keys, quotas and usage live in the gateway's sqlite
volume (gateway_data); back that volume up. Self-hosted deployments can
alternatively let users self-serve their own snk_ keys via POST /v1/keys
(see the API reference).
| Status | Meaning |
|---|---|
400 | Invalid name, quota or rate |
401 | Missing or wrong admin token; source .env again and check the running container's token |
First route
Prove the tiles built: a UK truck route (Dover → Birmingham), 4.0 m high,
44 t, avoiding tunnels that forbid tunnel code D loads under ADR (the
European agreement on carriage of dangerous goods by road; tunnel codes B–E
restrict which tunnels a hazmat load may use; full table in
conventions):
ADR tunnel enforcement requires the MapMap Valhalla image, which the self-host distro does not yet ship by default. On the stock Valhalla the distro boots, the dimensions still apply and the profile is echoed back, but the tunnel code is not enforced, so treat this call as a proof the tiles built rather than a proof a tunnel was avoided.
curl -fsS -X POST http://localhost:8080/route \
-H "Authorization: Bearer snk_your_key" \
-H "Content-Type: application/json" \
-d '{
"locations": [
{"lat": 51.1279, "lon": 1.3134},
{"lat": 52.4862, "lon": -1.8904}
],
"costing": "truck",
"adr": {
"dimensions": {
"height_m": 4.0, "width_m": 2.55, "length_m": 16.5,
"gross_weight_t": 44.0, "axle_load_t": null, "axle_count": null
},
"tunnel_code": "D", "hazmat": true
}
}'
Truncated response: a standard Valhalla trip, with the applied ADR profile echoed back:
{
"trip": {
"locations": [ { "lat": 51.1279, "lon": 1.3134 }, { "lat": 52.4862, "lon": -1.8904 } ],
"legs": [ { "maneuvers": [ "…" ], "summary": { "time": 5400.0, "length": 160.0 }, "shape": "…" } ],
"summary": { "time": 5400.0, "length": 160.0 },
"status": 0,
"status_message": "Found route between points",
"units": "kilometers"
},
"adr": {
"dimensions": { "height_m": 4.0, "width_m": 2.55, "length_m": 16.5, "gross_weight_t": 44.0 },
"tunnel_code": "D", "hazmat": true
}
}
time is seconds; length follows trip.units. A 502 here means the
routing engine is unreachable or still building; check
docker compose logs -f valhalla.
What's in the distro
| Component | Role |
|---|---|
| Gateway | The API: routing, ADR, metered keys, OpenAPI, llms.txt (port 8080) |
| Routing engine | Route computation; stock Valhalla by default (see below) |
| Geocoder (optional) | Forward geocoding via Photon (geocoding compose profile) |
| MCP server (optional) | Agent access over HTTP (mcp compose profile, port 8200) |
ADR tunnel enforcement requires the MapMap Valhalla image, which the
self-host distro does not yet ship by default. Dimensional and hazmat
constraints work on the stock build; for tunnel-category enforcement run the
GraphHopper backend or the MapMap Valhalla image, and verify it by checking
that a code D load is refused a category E tunnel on a route you know.
/adr/check is authoritative on every backend.
Enable the optional services with compose profiles:
docker compose --profile geocoding up -d photon # needs the photon_data volume seeded first
docker compose --profile mcp up -d mcp # agents connect to http://<host>:8200/mcp
Two ways to enable geocoding. The first-party path needs no extra
service: point SN_GEOCODE_DIR at an sn-geocode index (a single
territory's geocode layer from a signed package, or a multi-territory
index merged with snfactory geocode-merge; the hosted gateway serves a
whole-world merge this way) and the gateway serves /geocode and
/geocode/reverse in-process from the same sn-geocode engine that runs
on-device: no JVM, no separate search cluster, and identical ranking
online and off. First-party POI hits include the details object of OSM
display tags (opening hours, phone, website, …) that the Photon path
cannot provide. This is the recommended path, and the only one that works
air-gapped. The Photon path above is
the fallback: it runs the Photon release JAR on a stock JRE, and its data
volume must be seeded once with the JAR and a search-index dump (commands in
the distro quickstart inside the checkout). First-party takes precedence
when both are set. MCP configuration examples are on the
MCP page.
Engines and optional services
-
SN_ROUTING_ENGINEselects the routing engine:valhalla(default),graphhopper, orauto. Most endpoints behave identically either way, but not all of them: see Backend support below before you pick.graphhopperandautoneed a GraphHopper server reachable atSN_GRAPHHOPPER_URL; it is not part of the default compose stack (a commented-out service block is provided).graphhopperserves every endpoint it can from GraphHopper;autouses Valhalla first and falls back to GraphHopper on a Valhalla backend error, for the endpoints GraphHopper supports. -
Route optimisation runs as a sidecar; set
SN_VROOM_URLto enablePOST /optimise. Without it, that endpoint returns503. Driver breaks, work-time limits and EU drivers'-hours generation are handled in the gateway on top of the solver; no extra service. See optimisation. -
First-party geocoding: set
SN_GEOCODE_DIRto a territory geocode index to serve/geocodeand/geocode/reversefromsn-geocodein-process (no Photon). See geocoding. -
Hosted tiles, styles, fonts and sprites are served from
SN_TILES_DIR(the<territory>.pmtilesarchives) andSN_MAP_ASSETS_DIR(glyphs and sprite sheets). -
Feedback and telemetry sinks, all off unless you set them, all wired in
distro/docker-compose.yml:Variable Service Enables SN_MAP_ISSUES_DIRboth The report_map_issueNDJSON queue; the gateway serves it atGET /admin/mcp-map-issues. Both services must agree on the path.SN_RETROS_DIRmcp Safety net for submit_integration_retro: every retro the gateway cannot take (no key, unreachable, rate-limited) is written here rather than lost. Set it even when the gateway path is configured. Nothing reads it automatically.SN_MCP_USAGE_DIRmcp One NDJSON line per MCP tool call ( {ts, tool, ok, duration_ms, error_code?}); never arguments or payloads. Unset = tracing only.SN_DEMAND_LOG_DIRgateway Coarsened demand tap: one line per metered geospatial request, timestamps floored to 5 minutes and coordinates truncated to ~5 km geohash cells. Never key ids or raw coordinates. SN_ACTIVATION_EMAILgateway Default off, and it sends real email. On a key's first successful metered call, sends one note through the configured mailer ( SN_RESEND_API_KEY/SN_MAIL_WEBHOOK_URL). Leave off unless you are the operator sending it; theactivation.first_successtracing event fires either way.Retros submitted against your deployment land in your own
integration_retrostable and are readable atGET /admin/retros; they are never forwarded to MapMap. That table is only reached by the gateway POST, so a deployment withSTUDIO_URLset but no gateway API key for the mcp service queues its retros inSN_RETROS_DIRinstead of the admin console: mint an ordinarysnk_…key and setGATEWAY_API_KEYif you want them in the console. Fuller operational notes, including the shared-path gotcha, are indeploy/hetzner/README.md§7b in the checkout. -
Per-MAU SDK metering: an MAU is a distinct
X-MapMap-Uservalue per identity per calendar month.SN_MAU_INCLUDED(default 1000) sets the included count;SN_PRICE_PER_MAU_PENCE(default 30, GBP pence) prices each new user beyond it. Billing is fail-open: an uncovered debit flags the identitymau_overdrawnfor the month and requests keep being served, never blocked. SetSN_PRICE_PER_MAU_PENCE=0to disable billing entirely (typical for self-host).
Backend support
SN_ROUTING_ENGINE is not a transparent switch. What differs:
| Endpoint | valhalla (default) | graphhopper | auto |
|---|---|---|---|
POST /route, GET /route/v1/* | Valhalla | GraphHopper | Valhalla, GraphHopper on a Valhalla backend error |
POST /matrix | Valhalla | GraphHopper | Valhalla, GraphHopper on a Valhalla backend error |
POST /isochrone | Valhalla | GraphHopper | Valhalla, GraphHopper on a Valhalla backend error |
POST /trace_route, POST /trace_attributes | Valhalla | GraphHopper | Valhalla, GraphHopper on a Valhalla backend error |
POST /locate, POST /centroid | Valhalla | 501 | Valhalla only, never falls back |
POST /elevation, POST /elevation/along | Valhalla | not available (no GraphHopper equivalent) | Valhalla only, never falls back |
Two behaviours to plan around:
/locateand/centroidanswer501on a GraphHopper-only deployment. GraphHopper's open-source server has no equivalent service. The status is stable, so clients can feature-detect on it.POST /elevationandPOST /elevation/alongare Valhalla-only for the same reason, and agraphhopperdeployment has no Valhalla client to serve them.- A GraphHopper backend ignores
exclude_polygonsandexclude_locationsonPOST /matrixandPOST /isochrone. The fields are accepted and the request succeeds; the exclusions are not applied. If your scenario analysis depends on closing a bridge or an area, run those endpoints on Valhalla.
Under auto, a Valhalla backend error falls back to GraphHopper for the
endpoints GraphHopper supports, which means a request carrying exclusions can
be answered by a backend that does not honour them. Treat exclusion-bearing
scenario work as Valhalla-only until the gateway refuses that case explicitly.
Truck and ADR requests are a separate preference from the engine setting:
when SN_GRAPHHOPPER_URL is set, requests carrying the ADR/truck extension
are routed to GraphHopper even in valhalla mode.
See what your box is serving
GET /status is a read-only view of this deployment: build and commit,
routing backend, uptime, every territory it serves (name, version, the date
of the OSM extract behind it, bounds and package size), the DEM sources you
declared, which optional features the environment switched on, how many API
keys are live and how many metered units the box has recorded today.
Open it in a browser and you get a self-contained page, no external assets and no JavaScript, so it renders on an air-gapped box:
open http://localhost:8080/status # macOS; xdg-open on Linux
Ask for it from a script and you get the same report as JSON:
curl -fsS http://localhost:8080/status \
-H "Authorization: Bearer snk_your_key" | jq
{
"status": "ok",
"backend": "valhalla",
"version": "0.3.0",
"build": "b3d6f4f2",
"uptime_s": 3725,
"territories": [
{
"id": "uk",
"name": "United Kingdom",
"version": "2026.0715.1732",
"data_updated": "2026-07-15",
"bounds": [-8.65, 49.86, 1.77, 60.86],
"package_bytes": 5658218253,
"tiles": true
}
],
"terrain": {
"elevation_enabled": true,
"dataset": "mapzen-terrain-tiles",
"resolution_m": 30
},
"features": { "elevation": true, "geocoding": true, "places": false },
"keys": { "active": 7 },
"usage_today": { "day": "2026-09-11", "units": 1234 }
}
The top-level version is the gateway release (the same figure GET /health
reports), and each territory's version is the calendar version its package
was built under (YYYY.MMDD.HHMM, UTC). The version, build and territory
fields above are the hosted gateway's own /status on 23 September 2026;
the rest is illustrative.
It is read-only. There are no admin actions on it, and it carries no key material, no per-key data and no PII: the key count is a count, today's meter is a total across every key, and the optional-feature block is booleans, so it tells you a geocoder is configured without telling you where it lives. Anything a status page could leak is not on it.
null means unknown, never zero and never "current": a territory with no
data_updated is one whose extract date the channel does not record.
Auth is a verified API key, like every other surface. An air-gapped or
internal box has no key to present, so set SN_STATUS_PUBLIC=true to serve
it keyless. The default is false; leave it there on anything reachable
from the internet.
/status always answers 200, even when the backend is degraded, because it
is a page rather than a probe. Point monitoring at /health, which answers
503 when the routing engine is unreachable.
Pointing clients at your instance
Everything that talks to the hosted gateway takes a base URL, so swap in your
own origin. The web SDK accepts baseUrl in its map options; the MCP server
in the compose stack already talks to your local engine directly. Put a
reverse proxy with TLS in front of port 8080 before exposing the gateway
beyond localhost; API keys travel in the Authorization header.
Production notes
- For production tiles, prefer a signed territory package
installed into the tiles volume over the OSM bootstrap build, and clear
VALHALLA_TILE_URLSfrom.envonce you manage tiles that way, so a container recreate never triggers an accidental rebuild. - Back up the
gateway_datavolume: it holds API keys, quotas and usage. - The checkout ships a full production guide covering sizing, backup, upgrades, hardening and air-gapped deployment.
Tile your own data
There is no tileset-upload service, hosted or otherwise. What a
self-hoster gets instead is the tiler: snfactory tile turns a GeoJSON
file into a PMTiles archive of vector tiles on your own machine, and the
gateway serves any archive you drop in SN_TILES_DIR. Your data never
leaves your infrastructure, because there is nowhere for it to go.
No distro needed to tile. With no install beyond Node 18,
npx @mapmap/tile depots.geojson -o my-depots.pmtiles --layer depots runs
the same tiler compiled to WebAssembly (also importable in the browser, as
import { tile } from "@mapmap/tile"), with the same flags and a
byte-identical archive. It is free to use under the Apache License 2.0, and
nothing is uploaded.
$ snfactory tile depots.geojson -o my-depots.pmtiles \
--layer depots --maxzoom 14 --attribution "(c) Example Council 2026"
wrote my-depots.pmtiles (3.2 MB): 5000 features into 17024 tiles,
layer "depots", z0-z14
bounds: -5.49604, 50.10028, 1.59839, 55.79900
tiles per zoom: z0:1 z1:2 ... z13:4938 z14:5191
next: copy it into the gateway's SN_TILES_DIR, then
GET /tiles/my-depots/{z}/{x}/{y}.mvt, /tiles/my-depots/tiles.json and /tiles/my-depots/style.json
source-layer in your style: "depots"
Then copy it into place and it is live. There is no index to update and nothing to register:
$ cp my-depots.pmtiles /data/tiles/
$ curl -H "Authorization: Bearer $KEY" \
https://your-gateway/tiles/my-depots/tiles.json
The filename is the id in the URL. SN_TILES_DIR is a bare pile of
archives, and the gateway resolves SN_TILES_DIR/<id>.pmtiles from the
path, so the stem must be lowercase letters, digits and hyphens.
my-depots.pmtiles serves at /tiles/my-depots/...; My_Depots.pmtiles
404s, and the tiler warns you at build time rather than leaving you to
find out from the browser console.
Inputs. A FeatureCollection, a single Feature or a bare geometry
in .geojson, or GeoJSON Lines (.geojsonl, .ndjson) with one feature
per line. Point, LineString, Polygon and the Multi forms of each,
in WGS84 longitude and latitude. Properties become MVT attributes:
strings, numbers and booleans carry across as themselves, nested objects
and arrays as compact JSON strings, and null properties are dropped
because a vector tile has no null.
Options, all optional except the output:
| Flag | Default | What it does |
|---|---|---|
-o, --out | required | The .pmtiles archive to write |
--layer | data | The MVT layer name, which is the source-layer your style must reference |
--minzoom / --maxzoom | 0 / 14 | Zoom range to build. Renderers overzoom past maxzoom, so deeper is rarely worth the tiles |
--name | the layer name | Human-readable title in the archive metadata |
--attribution | none | Attribution in the archive metadata. Required by ODbL if the data is derived from OpenStreetMap, and the tiler warns when you leave it off |
--force | off | Replace an existing archive rather than refusing |
A first look. /tiles/<id>/style.json for your archive is a minimal,
ready-to-render style that draws your data: its polygons as a light fill,
its lines and its points in one colour, on the layer you named with
--layer. Point any MapLibre-compatible viewer at it to check the archive
before you style it.
Styling it. For your own look, point a vector source at the
archive's TileJSON and write your own layers against source-layer:
map.addSource("depots", {
type: "vector",
url: `${GATEWAY}/tiles/my-depots/tiles.json?api_key=${KEY}`,
});
map.addLayer({
id: "depot-points",
type: "circle",
source: "depots",
"source-layer": "depots",
paint: { "circle-radius": 4, "circle-color": "#37d67a" },
});
Attribution travels with the archive. The --attribution you passed
the tiler is recorded in the archive, and the gateway's TileJSON and the
minimal style carry it, so the map shows your credit with no extra code.
An archive built without one falls back to the standard OpenStreetMap and
OpenMapTiles credits, which is right for OpenStreetMap-derived data and
wrong for your asset register: pass --attribution, or set
attribution on your own source.
The honest limits.
- The whole input is read into memory before tiling. A council asset export or a few hundred thousand features is comfortable; a national address file is not.
- The only generalisation is rounding to the tile grid, so a dense line at low zoom keeps more vertices than a Douglas-Peucker pass would leave. Tiles are correct, not minimal.
GeometryCollectionis rejected rather than split silently, so the feature count you put in is the feature count you get back.- One layer per archive. Two datasets means two archives and two sources.
- A zoom where every feature is too small to draw (building footprints at world zoom, say) gets no tiles, so the archive starts at the first zoom that has some, and the tiler tells you so.
- Tiles are served under the same key, rate and metering rules as any other tile request, so an interactive map view costs about one metered call rather than one per tile.
Operational detail, including why this is not a Planetiler job and how the
output is verified, is in docs/TILING-YOUR-OWN-DATA.md in the checkout.
Bring your own road truth
Not enabled in the distro, and not something the hosted API does. The format and its validator exist and are documented here; applying a file needs a build we do not yet ship. Read this as the shape of the thing, not as a feature you can switch on today.
Some map truth is yours and never reaches OpenStreetMap. A council holds the
traffic regulation orders. A quarry knows which haul roads are private. A depot
knows the yard speed limit. road-overrides.json is a file you write and your
instance reads, so that truth reaches routing without waiting for a map
refresh, and without your data going anywhere near ours.
{
"source": "surrey-cc",
"records": [
{
"id": "TRO-2026-0114",
"kind": "speed_override",
"speed_kph": 32,
"start": "2026-09-08T07:00:00Z",
"end": "2026-12-19T16:00:00Z",
"note": "20 mph school streets order, term time",
"ways": [{ "way_id": 4285121, "direction": "both" }]
},
{
"id": "HAUL-ROAD-3",
"kind": "closed",
"note": "private haul road",
"ways": [4285122]
}
]
}
Each record names an id, a kind, and the roads it covers. kind is
speed_override (with a speed_kph from 1 to 120), closed, or reopened,
which retracts a closed record of your own. start and end are RFC 3339
and both are optional: no window means in force until you remove the record.
Roads are named by OSM way_id, optionally with a direction of forward,
backward or both. A file-level source saves repeating it on every record,
and a JSON Lines file (one record per line, # comments allowed) parses too.
Precedence, most restrictive first. A closure from a live incident feed
beats an override; your closed beats your speed_override; either beats a
measured or forecast speed for the window it is in force; outside every window
the engine falls back to its own layers. Where two of your records cover one
road the stricter wins (closed over any speed, the lower of two speeds), so
the result never depends on the order you wrote them in. And reopened
retracts your own closure only: it can never reopen a road an incident feed
says is shut.
Check a file before it goes anywhere. The validator reads the file and nothing else, so you can run it on a laptop:
$ sn-traffic-live validate-overrides --file road-overrides.json
road-overrides.json: 5 record(s): 2 speed, 2 closed, 1 reopened;
1 link(s), 4 way(s); 3 open-ended, 1 pending, 1 already ended;
2 road(s) in force now
A malformed record exits non-zero naming the record and, in the JSON Lines framing, the line it is on. A valid file that changes nothing today says so rather than passing quietly.
The honest limits. Way ids resolve against a table built from your
deployed routing tiles, which means the table has to be regenerated whenever
you rebuild them: edge ids are not stable across rebuilds, and a stale table
would apply your rule to the wrong road. Long-distance searches can traverse
shortcut edges that the table never names, so an override may not bind on
every path over the road. Operations, the precedence table in full, and the
checklist of what still has to ship before an instance can apply one of these
live in docs/TRAFFIC-LIVE-OPS.md §5d in the source checkout.
Procurement pack
For buyer's counsel and security teams, the checkout ships a due-diligence pack: the published SLA (patch SLAs, support response targets, escrow, and the hosted-API availability commitment at /sla), a security overview, a pre-answered vendor-security questionnaire, the licence-compliance story (permissive-only gate, ODbL handling), a deployment checklist, and a committed CycloneDX SBOM with a licence inventory, regenerated and published with every tagged release.
Next steps
- Conventions: auth, error envelope, units, ADR tunnel codes
- Territories: pre-built territory packages and air-gapped installs
- MCP server: wire agents to your self-hosted stack
- API reference: every endpoint, including
/admin/*