# 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](/docs/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](/docs/territories); 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](mailto: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
```

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](https://download.geofabrik.de/) extract works. The production
alternative is a pre-built [territory package](/docs/territories), 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](/docs/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](/docs/conventions)):

```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

| Component | Role |
|---|---|
| Gateway | The API: routing, ADR, metered keys, OpenAPI, llms.txt (port 8080) |
| Routing engine | Route computation with the bounded ADR costing extension |
| Geocoder (optional) | Forward geocoding via Photon (`geocoding` compose profile) |
| MCP server (optional) | Agent access over HTTP (`mcp` compose profile, port 8200) |

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](/docs/mcp).

## Engines and optional services

- `SN_ROUTING_ENGINE` selects the routing engine: `valhalla` (default),
  `graphhopper`, or `auto`. Every endpoint behaves identically either way,
  but `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 from GraphHopper; `auto` uses Valhalla first and falls back to
  GraphHopper on a Valhalla backend error.
- 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](/docs/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](/docs/api-reference#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`:

  | Variable | Service | Enables |
  |---|---|---|
  | `SN_MAP_ISSUES_DIR` | both | The `report_map_issue` NDJSON queue; the gateway serves it at `GET /admin/mcp-map-issues`. **Both services must agree on the path.** |
  | `SN_RETROS_DIR` | mcp | 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_DIR` | mcp | One NDJSON line per MCP tool call (`{ts, tool, ok, duration_ms, error_code?}`); never arguments or payloads. Unset = tracing only. |
  | `SN_DEMAND_LOG_DIR` | gateway | 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_EMAIL` | gateway | **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; 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).

## 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](/docs/territories)
  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.

## Procurement pack

For buyer's counsel and security teams, the checkout ships a due-diligence
pack: an SLA template (patch SLAs, support tiers, escrow), 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](/docs/conventions): auth, error envelope, units, ADR tunnel codes
- [Territories](/docs/territories): pre-built territory packages and air-gapped installs
- [MCP server](/docs/mcp): wire agents to your self-hosted stack
- [API reference](/docs/api-reference): every endpoint, including `/admin/*`
