# Build a self-hosted map stack (replacing Mapbox for web maps, geocoding, and routing)

You are building a map service that our own apps call instead of Mapbox. It serves vector tiles and a basemap style to MapLibre GL JS, geocodes addresses forward and reverse with autocomplete, computes routes, renders static map PNGs with markers, and meters usage per API key. It covers one region to start (a country or a few states) and can grow to the planet by swapping one file. Everything runs on open data (OpenStreetMap via Protomaps) and open software, on one machine you control. Build it end to end. Correctness of coordinates and cache behavior matters more than features.

This is not a CRUD app. Most of the work is a Docker Compose file, a CLI for building tiles, a Caddy config, and a thin API in front of two open-source services. The dashboard is small.

## Stack

- **One Node 22 process** running Next.js (App Router), TypeScript, Tailwind. The `/v1/*` API, the dashboard, and the job scheduler all live in it. A second process only for the renderer, which needs its own Chromium.
- **SQLite** through `better-sqlite3` and Drizzle, WAL mode, file at `./data/app.db`. Migrations are checked in and run on boot. Keep the schema portable so a Postgres `DATABASE_URL` works later, but don't build for it now.
- **Tiles as a file on disk.** The PMTiles archive lives at `./data/tiles/<slug>.pmtiles`. Caddy serves it with `file_server` (HTTP range requests, ETags, and cache headers are built in, zero code). MapLibre reads it with the `pmtiles` protocol. Sprites and glyphs sit next to it under `./data/assets/`.
- **Local disk for files.** Batch CSVs and rendered PNGs live under `./data/files/<table>/<id>/`. The app serves them through an authenticated route with short-lived signed URLs it mints itself.
- **In-process scheduler.** `croner` inside the app for the batch geocode runner, temp key expiry, render cache pruning, and limit alerts. A `job_locks` row stops two instances from running the same job.
- **Sessions the app issues itself.** First run creates an admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD`, or prompts in the browser if unset. Teammates join through invite links. Signed httpOnly cookies.
- **Realtime** is server-sent events from an in-memory event bus (job progress, usage banner).
- **Email** through `nodemailer` with `SMTP_URL`. Without it, mail is written to `./data/outbox/*.eml` and logged. Invites, job-done notices, and 80% limit alerts all go through this.
- **Geocoding:** Photon (komoot) in Docker, loaded from a prebuilt country extract. **Routing:** Valhalla in Docker from a Geofabrik PBF. **Renderer:** Node on the Playwright image, one long-lived Chromium, `POST /render`.
- **UTC in the database,** local time in the UI, `date-fns-tz` for every conversion.
- **Client demo:** `public/demo.html` with MapLibre GL JS and the `pmtiles` script copied from `node_modules` into `public/vendor/`. No CDN, no SDK of our own.

Full profile: https://buildyourown.software/deploy.md

## Running it

- **Laptop:** `pnpm install && pnpm dev`. No env vars. The dashboard, keys, styles, and tiles work right away; `docker compose up -d photon valhalla renderer` adds geocoding, routing, and static images once their data has downloaded.
- **VPS:** one DNS record, `docker compose up -d`. Services: `caddy`, `app`, `photon`, `valhalla`, `renderer`, `backup`. Caddy gets its own TLS certificate. `./data` is a named volume. Only Caddy is exposed.
- **Size the box honestly.** A country-sized region needs about 8 GB RAM (Valhalla's graph build peaks near 4 GB, Photon holds about 2 GB) and about 20 GB disk for the archive, the index, and the graph. This is not a $5 VPS. The tiny sample region runs on a laptop.
- **Backups:** `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. The `backup` service runs it nightly. Tiles, the Photon index, and the Valhalla graph are rebuildable from the region config, so back up `app.db` and `./data/files` only.
- **Updates:** `git pull && docker compose up -d --build`. Migrations run on boot.

The only things this stack downloads from outside are one-time data files, like a model download: the Protomaps planet build (or your own planetiler output), a Geofabrik PBF, and the Photon country extract. No accounts anywhere. `SMTP_URL` is optional and only matters when you want invites and alerts to reach a real inbox from a VPS.

## Environment variables

Every one has a default that works on a laptop. Fail fast with a clear message only when a value is set and malformed.

- `DATA_DIR` (default `./data`), `PUBLIC_URL` (default `http://localhost:3000`), `SESSION_SECRET` (generated on first boot and stored at `./data/session-secret` if unset).
- `ADMIN_EMAIL`, `ADMIN_PASSWORD` (if unset, the first browser visit shows a setup form).
- `SMTP_URL` (unset means the outbox), `MAIL_FROM` (default `maps@localhost`).
- `PHOTON_URL` (default `http://localhost:2322`), `VALHALLA_URL` (default `http://localhost:8002`), `RENDERER_URL` (default `http://localhost:3001`). Compose sets these to the service names.
- `STATIC_SERVER_KEY`: a server-scope key the renderer uses to fetch styles. The seed script creates it and writes it to `./data/static-key` if unset.

Never log a full API key or the session secret.

## Region config

`config/region.json` drives every data step:

```json
{
  "slug": "new-england",
  "name": "New England",
  "bbox": [-73.73, 40.95, -66.88, 47.46],
  "country_code": "us",
  "geofabrik": "north-america/us-northeast",
  "protomaps_build": "20260901"
}
```

`bbox` is `[min_lon, min_lat, max_lon, max_lat]`. Everything in this project uses **lon, lat** order in arrays, matching GeoJSON and MapLibre. The only places that use lat, lon are the Photon and Valhalla request bodies, and those conversions live in one adapter file each.

## Data model

SQLite types only: `text`, `integer`, `real`. Ids are uuid strings in `text`. Every timestamp is an ISO 8601 UTC string in `text` (`2026-09-10T14:03:00.000Z`); there is no `timestamptz`, so the app must never write a local-time string. JSON and arrays are `text` holding JSON, parsed at the edge with a zod schema; there is no `jsonb`, so nothing queries inside them. Booleans are `integer` 0 or 1. All tables have `id`, `created_at`, `updated_at` unless noted.

- `tilesets`: `slug` (unique), `name`, `file_path` (e.g. `tiles/new-england.pmtiles`, relative to `DATA_DIR`), `min_lon`, `min_lat`, `max_lon`, `max_lat`, `center_lon`, `center_lat` (`real`), `center_zoom`, `minzoom`, `maxzoom`, `size_bytes` (`integer`), `vector_layers` (json text), `source` (`protomaps:20260901` or `planetiler`), `built_at`. Read bounds, zooms, and layers from the PMTiles header when registering. Never type them by hand.
- `styles`: `slug` (unique), `name`, `tileset_id`, `flavor` (`light` | `dark` | `white` | `grayscale` | `black`), `spec` (json text, a complete MapLibre style with placeholder host `{HOST}` in URLs), `published_spec` (json text, nullable), `published_at` (nullable).
- `style_versions`: `style_id`, `spec`, `created_at`. Keep the last 10 per style.
- `api_keys`: `name`, `prefix` (first 12 chars, for display), `key_hash` (sha256 hex, unique), `allowed_origins` (json text array of glob patterns like `https://*.example.com`; empty means any origin, which is only allowed for server-side scopes), `scopes` (json text array from `tiles`, `geocode`, `route`, `static`), `monthly_limits` (json text: `{ "map_load": 500000, "geocode": 200000, "route": 100000, "static": 50000 }`), `expires_at` (nullable, for temp Maputnik keys), `revoked_at` (nullable), `last_used_at` (nullable).
- `usage_daily`: `api_key_id`, `product` (`map_load` | `tile` | `geocode` | `reverse` | `route` | `static` | `rejected`), `day` (`text`, `YYYY-MM-DD` in **UTC**), `count` (`integer`, 64-bit in SQLite so no `bigint` needed). Unique on (`api_key_id`, `product`, `day`). Upsert with `ON CONFLICT DO UPDATE SET count = count + 1`. No `id`.
- `geocode_jobs`: `api_key_id`, `filename`, `column_map` (json text: either `{ "address": "Full Address" }` or `{ "street": "...", "city": "...", "postcode": "...", "country": "..." }`), `total_rows`, `done_rows`, `failed_rows`, `status` (`queued` | `running` | `done` | `failed`), `input_path`, `output_path` (nullable), `error` (nullable), `finished_at` (nullable). Files live at `./data/files/geocode_jobs/<id>/input.csv` and `output.csv`.
- `users`: `email` (unique), `name`, `password_hash` (argon2), `role` (`admin`), `invited_by` (nullable). `invites`: `email`, `token_hash`, `expires_at`, `accepted_at` (nullable). `sessions`: `user_id`, `token_hash`, `expires_at`.
- `job_locks`: `name` (primary key), `locked_by` (process id plus a random suffix), `locked_at`, `expires_at`. A job runs only if it inserted the row or the existing row's `expires_at` is in the past.

Key format: `mk_live_` followed by 32 random base62 characters. Generate with `crypto.getRandomValues`. Show the full key once on creation, then only the prefix.

## Docker Compose

- `caddy`: `caddy:2`, ports 80 and 443, volumes `caddy-data` and `./data:/data/maps:ro`. The Caddyfile:
  - `handle_path /tiles/*` and `handle_path /assets/*`: `file_server` from `/data/maps/tiles` and `/data/maps/assets`. Caddy answers `Range` requests with 206, sends `Accept-Ranges: bytes` and an `ETag`, and honors `If-None-Match`. Add `header Cache-Control "public, max-age=86400"` on tiles and `"public, max-age=31536000, immutable"` on assets, `Access-Control-Allow-Origin *` on both, and `header Content-Type application/x-protobuf` for `*.pbf`.
  - Everything else: `reverse_proxy app:3000`.
  - Say in the README why tiles go through Caddy and not the app: zero code, range requests and caching built in, and a static file is the fastest thing a web server does.
- `app`: built from the repo, runs `next start` with the scheduler inside it. Port 3000 on the compose network only. Volume `./data:/app/data`.
- `photon`: an image that downloads the country extract for `country_code` on first start into volume `photon-data` (the `rtuszik/photon-docker` image reads `COUNTRY_CODE`). Port 2322. Healthcheck: `GET /api?q=test` returns 200. About 2 GB RAM for one mid-sized country.
- `valhalla`: `ghcr.io/gis-ops/docker-valhalla/valhalla`, mounts `data/valhalla` as `custom_files`, builds tiles on first start (10 to 40 minutes for a US region), volume `valhalla-tiles`. Port 8002. Healthcheck: `GET /status`. About 4 GB RAM during the build.
- `renderer`: built from `renderer/Dockerfile` on `mcr.microsoft.com/playwright:<pinned>-jammy`. Port 3001. Healthcheck: `GET /healthz`. `shm_size: 1gb`.
- `backup`: an alpine image with `sqlite3`, runs `pnpm backup`'s script nightly at 03:00 UTC against `/app/data`.
- Only `caddy` is exposed. Everything else is reachable on the compose network only.
- `docker-compose.test.yml` overrides the region to the tiny sample so the whole stack starts in a few minutes.

In dev, `pnpm dev` runs without Caddy. The app has one small fallback route for `/tiles/*` and `/assets/*` that streams the file with `Range` support (`createReadStream({ start, end })`, 206, `Content-Range`) so the laptop works alone. In compose, Caddy matches those paths first and the app never sees them.

## Tile pipeline (CLI in `scripts/`)

Expose these as `pnpm` scripts. Each prints what it's doing and exits non-zero on failure.

1. `pnpm tiles:build`: reads `config/region.json` and runs `pmtiles extract https://build.protomaps.com/<protomaps_build>.pmtiles data/tiles/<slug>.pmtiles --bbox=<bbox>`. Install the `pmtiles` CLI if missing and say how. With `--planetiler`, download the Geofabrik PBF and run planetiler instead, producing the same output path.
2. `pnpm tiles:register`: reads the PMTiles header and metadata with the `pmtiles` library's file source and upserts the `tilesets` row: bounds, center, zooms, size, `vector_layers`, source, built_at. No upload step. The file is already where Caddy serves it.
3. `pnpm assets:sync`: copies sprites and glyph PBFs from the `protomaps/basemaps-assets` repo into `data/assets/sprites/v4/<flavor>.*` and `data/assets/fonts/<fontstack>/<range>.pbf`. Include `Noto Sans Regular`, `Noto Sans Medium`, and `Noto Sans Italic`.
4. `pnpm styles:seed`: for each flavor, generates a style with the `layers()` helper from `@protomaps/basemaps`, wraps it in a full style document (version 8, `sources.protomaps` as `{ type: "vector", url: "pmtiles://{HOST}/tiles/<slug>.pmtiles" }`, `sprite: {HOST}/assets/sprites/v4/<flavor>`, `glyphs: {HOST}/assets/fonts/{fontstack}/{range}.pbf`), validates it with `validateStyleMin` from `@maplibre/maplibre-gl-style-spec`, and upserts a `styles` row per flavor. Attribution text: `© OpenStreetMap contributors, © Protomaps`.
5. `pnpm data:routing`: downloads the Geofabrik PBF for `geofabrik` into `data/valhalla/` so the Valhalla container can build its graph on first start.
6. `pnpm backup`: `sqlite3 data/app.db ".backup data/backups/<YYYY-MM-DD>.db"`, then delete all but the newest 14.

## Tiles and styles (served routes)

### Tiles

`GET /tiles/<slug>.pmtiles` is a static file. The browser's `pmtiles` protocol reads the header, the directory, and each tile with `Range` requests; Caddy answers 206 with `Content-Range` and caches nothing itself because it doesn't have to. No key, no counting: tiles are the cheap part, and metering them would cost more than serving them. Missing tiles (outside bounds or past `maxzoom`) are handled by the client library, which returns an empty tile.

### Styles

`GET /styles/:slug.json?key=` (or `Authorization: Bearer`) in the app:

1. Read the key. Missing → 401 `{ "error": "missing_key" }`. Look up `sha256(key)` in `api_keys`. Missing, revoked, or expired → 403 `{ "error": "invalid_key" }`. Revocation is immediate; there is no cache in front of the table.
2. If the request has an `Origin` header and the key has `allowed_origins`, match with globs. No match → 403 `origin_not_allowed`. Requests without an `Origin` (curl, server-side) are allowed only if `allowed_origins` is empty. `tiles` must be in `scopes`.
3. Return `published_spec` with every `{HOST}` replaced by `PUBLIC_URL`, `Cache-Control: private, max-age=300`, `Access-Control-Allow-Origin` set to the request's Origin, and `Vary: Origin`.
4. After the response is sent, record one `map_load` for the UTC day.

A **map load** is one style fetch. Tiles are not counted against limits. This mirrors how Mapbox bills and keeps the metering cheap.

`GET /v1/tilesets/:slug` returns `{ slug, bounds, center, minzoom, maxzoom, vector_layers, url: "pmtiles://<PUBLIC_URL>/tiles/<slug>.pmtiles" }` for the dashboard and docs.

## App API (`/v1/*`)

Auth for every route: `key` query param or `Authorization: Bearer <key>`. Look up by hash. Check scope. Check the monthly limit for the product (sum of `usage_daily` for the current UTC month). Over the limit → 429 with `Retry-After` set to seconds until the first of next month, UTC. Then do the work, then upsert `usage_daily` (`count = count + 1`) and stamp `last_used_at`. Errors are always `{ "error": "<snake_case>", "message": "<human>" }`.

Coordinate validation everywhere: lon in `[-180, 180]`, lat in `[-90, 90]`, both finite numbers. Anything else → 400 `invalid_coordinates`. Parse `lon,lat` pairs strictly: two numbers, one comma.

### Geocoding

- `GET /v1/geocode?q=<text>&limit=5&lang=en&proximity=lon,lat&bbox=min_lon,min_lat,max_lon,max_lat&country=us`
  Calls Photon `GET /api?q&limit&lang&lat&lon&bbox`. Photon wants `lat` and `lon` as separate params and `bbox` in the same lon,lat order we use. Returns a GeoJSON `FeatureCollection` where each feature has `geometry.coordinates` as `[lon, lat]` and `properties`: `label` (one-line address built from house number, street, city, postcode, country), `match_level` (Photon's `type`: `house` | `street` | `locality` | `district` | `city` | `county` | `state` | `country`), `osm_id`, `osm_type`, and the raw address parts.
- `GET /v1/reverse?lon=&lat=&lang=en` calls Photon `/reverse?lat&lon&limit=1`. Same response shape. Empty result → 404 `no_result`.
- Autocomplete is the same `/v1/geocode` with `limit=5`. Add `Cache-Control: public, max-age=3600` to geocode responses so a browser typing the same prefix twice doesn't hit Photon twice.
- `POST /v1/geocode/batch` (multipart: `file`, `column_map` JSON). Limit 50,000 rows. Writes the CSV to `./data/files/geocode_jobs/<id>/input.csv`, creates a `geocode_jobs` row, returns `{ id }`. `GET /v1/geocode/batch/:id` returns status and, when done, a signed download URL (`/files/geocode_jobs/<id>/output.csv?exp=<unix>&sig=<hmac>`, valid 15 minutes, signed with `SESSION_SECRET`).

The batch runner is a `croner` job in the app process, every 5 seconds. It takes the `job_locks` row `geocode_runner` (expiry 60 s, renewed every 20 s while running), picks the oldest `queued` or `running` job, streams the CSV with `csv-parse`, geocodes 8 rows concurrently while preserving output order, and appends to `output.csv` with all original columns plus `lon`, `lat`, `match_level`, `matched_address`, `error`. Unmatched rows get empty `lon` and `lat` and `error = "no_match"`. Every 100 rows it updates `done_rows` and `failed_rows` and publishes `job.progress` on the event bus. On restart it counts the lines already in `output.csv`, reconciles with `done_rows`, and skips rows below that. Each row counts as one `geocode` in `usage_daily`. When the job finishes, email the uploader's admin address through nodemailer.

### Routing

- `GET /v1/route?from=lon,lat&to=lon,lat&via=lon,lat;lon,lat&profile=driving|cycling|walking&alternatives=0`
  Maps `profile` to Valhalla costing `auto` | `bicycle` | `pedestrian`. Posts to Valhalla `/route` with `locations: [{ lat, lon }, ...]` (the one place we flip order), `costing`, `units: "kilometers"`, `directions_options: { language: "en-US" }`. Decodes each leg's `shape` with polyline precision **6** (Valhalla's default; precision 5 will put the route in the ocean). Returns `{ geometry: <GeoJSON LineString>, distance_m, duration_s, legs: [{ distance_m, duration_s, steps: [{ instruction, distance_m, duration_s, begin_index }] }] }`. Max 25 locations total. Valhalla 400 (no route) → 404 `no_route`.
- `POST /v1/matrix` body `{ sources: [[lon,lat],...], targets: [[lon,lat],...], profile }` → Valhalla `/sources_to_targets`. Returns `{ durations_s: number[][], distances_m: number[][] }`. Max 50 × 50.
- `GET /v1/isochrone?center=lon,lat&minutes=15,30&profile=` → Valhalla `/isochrone` with `polygons: true`. Returns the GeoJSON polygons.

Matrix and isochrone are small. Build them last.

### Static images

`GET /v1/static/<lon>,<lat>,<zoom>/<width>x<height>[@2x].png?style=<slug>&markers=<lon>,<lat>[,<hex>][;...]&key=`
and `GET /v1/static/auto/<width>x<height>[@2x].png?markers=...&padding=40`

- Limits: width and height in `[1, 1280]`, zoom in `[0, 20]`, at most 50 markers, `@2x` doubles the device scale factor. Colors are 6-hex without `#`, default `e11d48`.
- `auto` fits the bounds of all markers with `padding` pixels using MapLibre's `fitBounds` in the renderer. One marker → zoom 14. Clamp lat to `±85.0511` before any tile math.
- Cache key: sha256 of the canonical parameter string. If `./data/files/renders/<hash>.png` exists, stream it. Otherwise `POST <RENDERER_URL>/render` with the params and the style JSON (with `{HOST}` replaced by `PUBLIC_URL` and `STATIC_SERVER_KEY` appended), write the PNG, and return it. Headers: `Content-Type: image/png`, `Cache-Control: public, max-age=604800`, `X-Cache: HIT|MISS`.
- Counts as one `static` per request, cache hit or not.
- A daily `croner` job `prune_renders` deletes PNGs not read in 30 days (track `atime`, or a `last_read_at` sidecar).

## Renderer service (`renderer/`)

- Express or Hono on port 3001. On boot, launch one Chromium and one `BrowserContext`. Reuse them. Restart Chromium if it crashes.
- `POST /render` body `{ style: <style JSON>, center: [lon, lat] | null, zoom, bounds: [[lon,lat],[lon,lat]] | null, padding, width, height, scale, markers: [{ lon, lat, color }] }`.
- Opens a page at `width × height` with `deviceScaleFactor: scale`, loads `renderer/page.html` (MapLibre GL JS and the `pmtiles` protocol bundled locally so renders don't depend on the internet), passes the params with `page.evaluate`. The page registers the `pmtiles` protocol, creates the map with `interactive: false`, `attributionControl: false`, `preserveDrawingBuffer: true`, adds each marker as a `maplibregl.Marker` with a custom element: a 14 px circle in the given color, 2 px white border, anchored at `center`. Then it waits for `map.once('idle')` and resolves.
- Screenshot the viewport as PNG and return it. Timeout 15 seconds → 504 `render_timeout`.
- Draw attribution as a 10 px text overlay in the bottom-right: `© OpenStreetMap contributors`.
- Because markers are circles anchored at center, a marker placed at the map center is exactly at pixel `(width/2, height/2)`. Tests depend on this.

## Style editing rules

- A style has a draft (`spec`) and a published copy (`published_spec`). Publish copies the draft after validation. The style route only ever reads `published_spec`.
- Validation runs `validateStyleMin` and also checks that every `source.url`, `sprite`, and `glyphs` value starts with `{HOST}/` or `pmtiles://{HOST}/`. Reject anything pointing at another host so a style can't quietly load tiles from Mapbox.
- Every layer's `source-layer` must exist in the tileset's `vector_layers`. Warn on unknown ones, don't block.
- "Open in Maputnik" mints a temporary browser key scoped to `tiles` with `allowed_origins = ["https://maputnik.github.io"]` and `expires_at` one hour out. The `expire_temp_keys` job revokes it. Show a note explaining that. Maputnik is a static page on GitHub Pages; you can also run it locally from its Docker image and point it at `localhost`.
- Pasting Maputnik output back replaces the draft. Write the previous draft to `style_versions` and trim to 10 so a bad paste can be undone.
- The default flavors ship with `lang: "en"`. Expose a per-style `lang` field that regenerates label expressions when changed.

## Usage metering and jobs

- One function `recordUsage(apiKeyId, product, at)` does every write. `day = at` truncated to a date **in UTC**. Upsert `usage_daily`. Never use server local time. Every caller runs it after the response is sent.
- `GET /v1/usage?from=YYYY-MM-DD&to=YYYY-MM-DD` (requires an admin session; API keys can't call it) returns rows grouped by key, product, and day. Dates are UTC days.
- Per-key burst limit: 600 requests per minute per key using an in-memory sliding window. Over → 429 `rate_limited`, `Retry-After: 60`.
- Scheduled jobs, all `croner`, all through `job_locks`: `geocode_runner` (every 5 s), `expire_temp_keys` (every minute), `prune_renders` (daily 04:00 UTC), `limit_alerts` (hourly: email once per key per month when any product crosses 80% of its limit; record the send in a `limit_alerts_sent` table so it doesn't repeat).
- `GET /api/events` is an SSE stream for signed-in users. The bus publishes `job.progress`, `job.done`, and `usage.alert`. The Jobs and Overview screens subscribe to it. No polling.

## Edge cases to handle

- Longitude exactly `180` or `-180` is valid. A bbox that crosses the antimeridian (`min_lon > max_lon`) is rejected with 400 `bbox_crosses_antimeridian` in v1; document it.
- Latitude above `85.0511` is valid input for geocoding and routing but must be clamped before Web Mercator tile math in the static renderer.
- `lon,lat` parsing: trim whitespace, reject `NaN`, `Infinity`, empty parts, and more than two parts. `-0` is fine.
- A key sent with surrounding whitespace or as `Bearer  <key>` with two spaces should still match after trimming.
- CSV inputs may have a UTF-8 BOM, quoted commas, Windows line endings, and blank trailing lines. Handle all four. A blank address row is written back with `error = "empty_input"` so row counts match. Don't skip it.
- Photon returns nothing for an empty or one-character `q`. Return an empty `FeatureCollection` with 200, don't call Photon.
- If Photon, Valhalla, or the renderer is down, return 503 `upstream_unavailable` with `Retry-After: 5`, and do not count usage for that request. On a laptop before `docker compose up`, this is the normal state, and the Playground says so instead of showing a stack trace.
- Duplicate markers at the same point render once. Markers outside the viewport are still included in `auto` bounds.
- Two identical static requests in flight at the same time must not both call the renderer. Use a per-hash in-process lock.
- The usage upsert for the 101st request must happen even though the response is 429, so the dashboard can show attempts. Count it under a separate product `rejected`.
- A job lock whose `expires_at` has passed belongs to a dead process. Take it over and log that you did. A process that loses its lock (renewal fails) stops at the next row boundary.
- SQLite is single-writer. Keep every usage write inside one short transaction and set `busy_timeout` to 5000 so a burst of API calls queues instead of failing with `SQLITE_BUSY`.
- Month rollover: a request at `2026-09-30T23:59:59Z` counts toward September, `2026-10-01T00:00:00Z` toward October, in every server timezone.

## Screens (admin, behind the app's own login)

1. **Overview (`/`).** This month's usage per product (UTC month, and the label says "UTC"), a bar chart per day, and the tilesets and styles currently published. A banner when any key is above 80% of a monthly limit, updated over SSE.
2. **API keys (`/keys`).** List with prefix, name, scopes, origins, last used, this month's counts. Create modal: name, scopes, origins (one per line), monthly limits. Shows the full key once with a copy button. Revoke with confirmation; it takes effect on the next request.
3. **Tilesets (`/tilesets`).** Rows from the table with bounds drawn on a small map, size, source, built date, and the `pmtiles://` URL. A "how to add a region" panel that prints the exact `pnpm` commands.
4. **Styles (`/styles`, `/styles/[slug]`).** List by flavor. Detail: a live MapLibre preview on the left, the JSON on the right in a code editor with validation errors inline, and buttons: Save draft, Publish, Open in Maputnik (opens `https://maputnik.github.io/editor/?style=<style URL with a temporary key>`), Duplicate.
5. **Geocoding jobs (`/jobs`).** Upload form with column mapping (auto-detect columns named like `address`, `street`, `city`, `zip`, `postcode`, `country`), progress bars fed by SSE, download link, and a map of matched points when done.
6. **Playground (`/playground`).** Tabs for Geocode, Reverse, Route, Static. Each shows the request URL as you fill the form, the raw JSON response, and the result on a map. Reverse lets you click the map to set `lon,lat`.
7. **Team (`/team`).** Admins, pending invites, and an "Invite" form that emails a link (or shows the outbox path in dev).
8. **Docs (`/docs`).** Generated from one markdown file that documents every `/v1` endpoint, the style route, and the tile file, with a copy-paste MapLibre snippet (including the `pmtiles` protocol registration) using a real key selected from a dropdown.

## Demo page

`public/demo.html`: `vendor/maplibre-gl.js` and `vendor/pmtiles.js`, `maplibregl.addProtocol("pmtiles", new pmtiles.Protocol().tile)`, `new maplibregl.Map({ container, style: "<PUBLIC_URL>/styles/light.json?key=<key>", center: [<region center>], zoom: 12 })`, a geocoder input that calls `/v1/geocode` with debounce 150 ms, and a "route from here to there" button that draws `/v1/route` as a line layer. This page is how you check the whole thing works.

## Non-goals

Do not build: turn-by-turn navigation, live traffic, satellite imagery, a mobile SDK, multi-tenant accounts, billing or Stripe, planet-wide geocoding, a z/x/y tile route (the `pmtiles` protocol covers web and MapLibre Native), an AI feature of any kind. No hosted services beyond the one-time data downloads (Protomaps build or planetiler output, Geofabrik PBF, Photon country extract) and an optional SMTP relay. Nominatim and OSRM are documented as alternatives. Don't build them. If you're tempted, add a TODO comment.

## Acceptance criteria

1. `pnpm tiles:build && pnpm tiles:register` with the sample region produces `./data/tiles/<slug>.pmtiles` and a `tilesets` row whose bounds, `minzoom`, `maxzoom`, and `vector_layers` match the PMTiles header. `GET /v1/tilesets/<slug>` returns them and a `pmtiles://` URL on `PUBLIC_URL`.
2. `curl -r 0-16383 <PUBLIC_URL>/tiles/<slug>.pmtiles` returns 206 with `Content-Range`, `Accept-Ranges: bytes`, `Cache-Control: public, max-age=86400`, and an `ETag`. Repeating it with `If-None-Match` returns 304. This holds under `pnpm dev` (the fallback route) and under compose (Caddy).
3. A style fetch with no key returns 401. A revoked key returns 403 on the very next request. A valid key with `allowed_origins = ["https://example.com"]` returns 403 when `Origin: https://evil.com` and 200 when `Origin: https://example.com`. Tile range requests need no key.
4. Fetching a style JSON once and then 200 tile range requests produces exactly one `map_load` increment and zero `tile` increments against limits.
5. `public/demo.html` renders the region at zoom 12 through 16 with labels and road names, and the browser makes network requests only to `PUBLIC_URL`. No request goes to any other domain.
6. `GET /v1/geocode?q=<a known street address in the region>` returns a `FeatureCollection` whose first feature has `geometry.coordinates` as `[lon, lat]` within 100 m of the true location and `match_level = "house"`. With `bbox` set to a box that excludes that address, it's not in the results. With `proximity` set near a duplicate street name in another town, that town's result comes first.
7. `GET /v1/reverse?lon=&lat=` for a point in the region returns the nearest address. `lat=95` returns 400 `invalid_coordinates`. `lon=lat` swapped (a point in the ocean) returns 404 `no_result` and does not crash.
8. A 1,000-row batch job finishes, the output CSV has exactly 1,000 rows in the original order with all original columns plus the five new ones, rows that can't match have empty `lon` and `lat` and `error = "no_match"`, `usage_daily.geocode` grew by 1,000, and the download link is a signed URL that stops working after 15 minutes. Killing the app at row 500 and restarting finishes the job without duplicating rows.
9. `GET /v1/route` between two points in the region returns a GeoJSON `LineString` whose first coordinate is within 50 m of `from` and last is within 50 m of `to`, with positive `distance_m` and `duration_s`. `profile=walking` returns a longer `duration_s` than `profile=driving` for a 5 km trip. A `to` point in the ocean returns 404 `no_route`.
10. `GET /v1/static/<center>,14/200x200.png?markers=<center>,ff0000` returns a 200 × 200 PNG whose pixel at (100, 100) is red (`r > 200, g < 60, b < 60`), and the file exists under `./data/files/renders/`. The same URL again returns `X-Cache: HIT` in under 100 ms. `@2x` returns a 400 × 400 image. Width 2000 returns 400.
11. Two `recordUsage` calls with `at = "2026-09-10T23:59:30Z"` and `at = "2026-09-11T00:00:30Z"` create rows for `2026-09-10` and `2026-09-11` regardless of the server's `TZ`. The Overview shows both under the right day.
12. A key with `monthly_limits.geocode = 100` gets 200 for the first 100 geocodes of the UTC month and 429 with a correct `Retry-After` on the 101st. The Overview shows the key at 100%, and one alert email went out when it crossed 80.
13. `docker compose down && docker compose up -d` keeps the archive, the Photon index, the Valhalla graph, and `app.db`. Nothing re-downloads and the demo page works within a minute.
14. A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
15. `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
16. Killing the process mid-job and restarting it doesn't double-run or lose the job.
17. `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
18. Every outbound email in dev shows up in `./data/outbox`.

## Deliverables

- The app, the renderer, `docker-compose.yml`, `Caddyfile`, `scripts/backup.sh`, migrations, and the `scripts/` CLI.
- `config/region.json` for a small sample region that builds in under 10 minutes on a laptop, plus instructions for swapping to your own.
- A seed script that creates the admin (from env or the setup form), two keys (one browser key with origins, one server key), publishes the `light` and `dark` styles, and queues a 50-row sample geocode job from `fixtures/addresses.csv`.
- `public/demo.html` and `public/vendor/`.
- README with: laptop setup (`pnpm dev`, then which compose services to start and how long their first-start downloads take), VPS setup (DNS, `docker compose up -d`, the 8 GB / 20 GB sizing), why Caddy serves tiles, backup and restore, the three one-time outside downloads and the optional SMTP relay, RAM and disk per region size, and a section titled "When to keep paying Mapbox" that says turn-by-turn navigation, traffic, and satellite imagery.

Build the tile pipeline, the style route, and the demo page first and get a map on screen with `pnpm dev`. Then keys and usage. Then geocoding, routing, static images, the dashboard, and last the compose file with Caddy. Run the demo page in a browser after each step.
