Skip to content
Documentation menu
docs / self-host · raw .md
docker compose up: a whole maps stack · 2:24 · all videos

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

sh
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:

sh
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:

sh
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:

sh
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:

sh
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:

json
{
  "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).

StatusMeaning
400Invalid name, quota or rate
401Missing 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.

sh
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:

json
{
  "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

ComponentRole
GatewayThe API: routing, ADR, metered keys, OpenAPI, llms.txt (port 8080)
Routing engineRoute 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:

sh
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_ENGINE selects the routing engine: valhalla (default), graphhopper, or auto. Most endpoints behave identically either way, but not all of them: see Backend support below before you pick. graphhopper and auto need a GraphHopper server reachable at SN_GRAPHHOPPER_URL; it is not part of the default compose stack (a commented-out service block is provided). graphhopper serves every endpoint it can from GraphHopper; auto uses 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_URL to enable POST /optimise. Without it, that endpoint returns 503. 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_DIR to a territory geocode index to serve /geocode and /geocode/reverse from sn-geocode in-process (no Photon). See geocoding.

  • Hosted tiles, styles, fonts and sprites are served from SN_TILES_DIR (the <territory>.pmtiles archives) and SN_MAP_ASSETS_DIR (glyphs and sprite sheets).

  • Feedback and telemetry sinks, all off unless you set them, all wired in distro/docker-compose.yml:

    VariableServiceEnables
    SN_MAP_ISSUES_DIRbothThe report_map_issue NDJSON queue; the gateway serves it at GET /admin/mcp-map-issues. Both services must agree on the path.
    SN_RETROS_DIRmcpSafety 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_DIRmcpOne NDJSON line per MCP tool call ({ts, tool, ok, duration_ms, error_code?}); never arguments or payloads. Unset = tracing only.
    SN_DEMAND_LOG_DIRgatewayCoarsened 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_EMAILgatewayDefault 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; the activation.first_success tracing event fires either way.

    Retros submitted against your deployment land in your own integration_retros table and are readable at GET /admin/retros; they are never forwarded to MapMap. That table is only reached by the gateway POST, so a deployment with STUDIO_URL set but no gateway API key for the mcp service queues its retros in SN_RETROS_DIR instead of the admin console: mint an ordinary snk_… key and set GATEWAY_API_KEY if you want them in the console. Fuller operational notes, including the shared-path gotcha, are in deploy/hetzner/README.md §7b in the checkout.

  • Per-MAU SDK metering: an MAU is a distinct X-MapMap-User value 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 identity mau_overdrawn for the month and requests keep being served, never blocked. Set SN_PRICE_PER_MAU_PENCE=0 to disable billing entirely (typical for self-host).

Backend support

SN_ROUTING_ENGINE is not a transparent switch. What differs:

Endpointvalhalla (default)graphhopperauto
POST /route, GET /route/v1/*ValhallaGraphHopperValhalla, GraphHopper on a Valhalla backend error
POST /matrixValhallaGraphHopperValhalla, GraphHopper on a Valhalla backend error
POST /isochroneValhallaGraphHopperValhalla, GraphHopper on a Valhalla backend error
POST /trace_route, POST /trace_attributesValhallaGraphHopperValhalla, GraphHopper on a Valhalla backend error
POST /locate, POST /centroidValhalla501Valhalla only, never falls back
POST /elevation, POST /elevation/alongValhallanot available (no GraphHopper equivalent)Valhalla only, never falls back

Two behaviours to plan around:

  • /locate and /centroid answer 501 on 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 /elevation and POST /elevation/along are Valhalla-only for the same reason, and a graphhopper deployment has no Valhalla client to serve them.
  • A GraphHopper backend ignores exclude_polygons and exclude_locations on POST /matrix and POST /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:

sh
open http://localhost:8080/status        # macOS; xdg-open on Linux

Ask for it from a script and you get the same report as JSON:

sh
curl -fsS http://localhost:8080/status \
  -H "Authorization: Bearer snk_your_key" | jq
json
{
  "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_URLS from .env once you manage tiles that way, so a container recreate never triggers an accidental rebuild.
  • Back up the gateway_data volume: 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.

console
$ 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:

console
$ 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:

FlagDefaultWhat it does
-o, --outrequiredThe .pmtiles archive to write
--layerdataThe MVT layer name, which is the source-layer your style must reference
--minzoom / --maxzoom0 / 14Zoom range to build. Renderers overzoom past maxzoom, so deeper is rarely worth the tiles
--namethe layer nameHuman-readable title in the archive metadata
--attributionnoneAttribution in the archive metadata. Required by ODbL if the data is derived from OpenStreetMap, and the tiler warns when you leave it off
--forceoffReplace 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:

ts
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.
  • GeometryCollection is 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.

json
{
  "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:

console
$ 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/*