The self-host distro: four services, one Compose file

# The self-host distro: four services, one Compose file

MapMap's self-host distro is a Docker Compose stack of four services: a gateway on port 8080, our routing engine, an optional geocoder and an optional MCP server on port 8200. Two of the four stay off until you turn them on. It runs air-gapped once you install a pre-built territory package instead of bootstrapping tiles from an OpenStreetMap extract, and every deployment serves its own `/openapi.json` and `/llms.txt`, so an agent inside your network reads the contract off your box rather than ours. It is a commercial licence rather than free software, and the ops burden is genuinely yours.

## What is actually in the Compose file?

Four services, two of them optional and off by default.

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

The optional pair are compose profiles, so nothing runs that you did not ask for:

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

That is the whole inventory. There is no message broker, no separate search cluster on the recommended path and no vendor callback anywhere in it.

## What does the first boot actually involve?

A tile build, and for a UK-scale territory it takes 30 to 60 minutes. Budget for it rather than assuming the stack is broken.

The prerequisites are short:

- Docker Engine 24+ with Compose v2 (`docker compose version`)
- Roughly 10 GB of disk and 8 GB of RAM for a UK-scale first tile build. Serving afterwards needs less
- Roughly 1.5 GB once, for shared global base-map sources (ocean water polygons, Natural Earth)
- Outbound internet on first boot only, for the OSM extract and those base-map sources

The sequence itself is five commands. `SN_ADMIN_TOKEN` protects `/admin/*` and Compose refuses to start without it, which is deliberate:

```sh
cd distro
cp .env.example .env
sed -i '' "s/^SN_ADMIN_TOKEN=$/SN_ADMIN_TOKEN=$(openssl rand -hex 32)/" .env # macOS
docker compose up -d --build
docker compose logs -f valhalla
```

One gotcha is worth knowing before it costs you an hour. While tiles build, `docker compose ps` shows the gateway sitting in `Created`. That is expected, not a failure: the gateway waits on the routing engine's healthcheck. You are up when this returns:

```sh
curl -fsS http://localhost:8080/health
```

For a bootstrap build straight from OSM, set `VALHALLA_TILE_URLS` in `.env` to any [Geofabrik](https://download.geofabrik.de/) extract. The production alternative is a pre-built signed [territory package](/docs/territories), which skips the build entirely and is the only path that works air-gapped.

## How do you get a key on your own instance?

Through `/admin/keys`, authenticated with your admin token, and all three body fields are required because 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 and only its BLAKE3 hash is persisted, so put it in your secret manager on the way past. Keys, quotas and usage live in the gateway's sqlite volume, `gateway_data`. That is the volume to back up, and it is also the reason your usage data is a file on your disk rather than a row in our database.

## Why does an air-gapped deployment still serve /llms.txt?

Because the contract should come from the machine that answers the calls, not from a marketing site an agent cannot reach. Every gateway deployment serves its own `/openapi.json`, which is the authoritative contract, and its own `/llms.txt`. Inside a closed network with no route to `mapmap.ai`, a coding agent pointed at your gateway can still discover what endpoints exist, what parameters they take and what comes back.

The MCP server in the compose stack talks to your local engine directly, so agents on the `mcp` profile at port 8200 are routing against your tiles, under your keys, without leaving your boundary. An air-gapped stack that is still agent-discoverable is an odd combination, and it is why the contract ships with the deployment instead of living on a website your network cannot reach.

## Which switches matter when you size this?

Six environment variables, and each one changes either what you have to run or what you are billed for.

| Variable | Effect |
|---|---|
| `SN_ROUTING_ENGINE` | `valhalla` (default), `graphhopper` or `auto`. Every endpoint behaves identically either way, but `graphhopper` and `auto` need a server at `SN_GRAPHHOPPER_URL` and it is not in the default stack |
| `SN_VROOM_URL` | Enables `POST /optimise`. Without it that endpoint returns `503`, deliberately |
| `SN_GEOCODE_DIR` | Serves `/geocode` and `/geocode/reverse` in-process from `sn-geocode`, with no JVM and no Photon. The recommended path, and the only one that works air-gapped |
| `SN_TILES_DIR`, `SN_MAP_ASSETS_DIR` | Hosted tiles, styles, fonts and sprites: the `<territory>.pmtiles` archives, and glyphs plus sprite sheets |
| `SN_PRICE_PER_MAU_PENCE` | Per-MAU SDK metering. Set to `0` to disable billing entirely, which is typical for self-host |

Geocoding deserves the extra sentence. The first-party path needs no extra service: point `SN_GEOCODE_DIR` at the geocode index that the map factory builds into every signed territory package, and the same engine that runs on-device serves search in-process, with identical ranking online and off. Photon is the fallback, it wants a JRE and a seeded data volume, and first-party takes precedence when both are set.

## What is in the procurement pack?

A due-diligence bundle that ships in the checkout, aimed at buyer's counsel and security teams rather than engineers:

- An SLA template covering patch SLAs, support tiers and escrow
- A security overview
- A pre-answered vendor-security questionnaire
- The licence-compliance story: the permissive-only gate and ODbL handling
- A deployment checklist
- A committed CycloneDX SBOM with a licence inventory, regenerated and published with every tagged release

The point of shipping it rather than producing it on request is that your legal team can check rather than trust us. The licensing gate behind it is covered in [permissive licensing and the SBOM we publish per release](/news/permissive-licensing-sbom).

## What does self-hosting cost?

A commercial platform licence plus data plus support, quoted annually, because fleet size and territory count set the number.

| Component | Billing | What it covers |
|---|---|---|
| Commercial platform licence | Annual, quoted | Tiered by fleet size: up to 100 vehicles, up to 1,000 vehicles, unlimited/enterprise |
| Territory data subscription | Per territory, per year | Signed, QA'd, restriction-audited territory packages with ongoing OTA updates |
| Support and SLA | Tiered | Standard, priority, or escrow and 24/7. Security-patch SLAs and source escrow |
| Professional services | Quoted | Custom territory builds, bespoke restriction overlays, integration |

The licence includes the Docker distro, the signed packages and OTA channel, the SBOM and licence-compliance pack plus support and patch SLAs per the procurement pack. It is not self-serve: email [hello@mapmap.ai](mailto:hello@mapmap.ai) with your fleet size and the territories you need. The hosted API needs no conversation by contrast, at 50,000 included calls a month after email verification, then 0.05p per standard call and 1p per truck or ADR call.

## What we are not going to pretend

- **You own the ops.** Back up `gateway_data`, because it holds your keys, quotas and usage. Put a TLS reverse proxy in front of port 8080 before exposing it beyond localhost, because API keys travel in the `Authorization` header. Nobody pages themselves at 03:00 on your behalf unless you bought the tier that says so.
- **It is not free software.** Self-hosting buys control, not a zero on the invoice. If you want free, the hosted free tier is genuinely free and commercial use is allowed on it.
- **There is no public clone URL.** The repository is private while we finish the sdk-v1 release. Source access is real and granted by email at [hello@mapmap.ai](mailto:hello@mapmap.ai), but you cannot evaluate the distro anonymously today, and that is friction we have not removed yet.
- **Two features need a service you must run.** GraphHopper is not in the default stack, and `POST /optimise` returns `503` until `SN_VROOM_URL` is set. Neither is a config typo when you hit it.
- **The bootstrap path is not the air-gapped path.** A first run from an OSM extract needs outbound internet and an hour of tile building. Air-gapped means pre-built territory packages, and that is a different install.

## Where to start

Read the [self-hosting guide](/docs/self-host) for the full boot sequence, the admin endpoints and the procurement pack, then email [hello@mapmap.ai](mailto:hello@mapmap.ai). If you want the reasoning behind running navigation on your own hardware at all, rather than the mechanics of it, [sovereign navigation](/news/sovereign-navigation) makes that case. Routing and map data derive from OpenStreetMap, so credit "© OpenStreetMap contributors" when you render or republish it.