Architecture
Dawarich Atlas is built around eight design principles:
- Local-first. Every layer runs on your hardware. No outbound API calls at runtime.
- Open data only. OSM, SRTM, GTFS — all freely downloadable.
- MapLibre GL as the renderer. Vector-first.
- PMTiles as the tile format. Static files, no tile server process.
- Minimal overlap, minimal resource consumption. Each service owns a distinct query type.
- Single compose file. One
compose.yml, bind mounts under./data/, region selected via.env. - Caddy as the edge. Auto-HTTPS-ready, range requests + compression out of the box.
- One Phoenix codebase, one Docker socket. The Phoenix layer is the whole public surface (API + admin UI + LiveView). Its in-app control plane mounts the host Docker socket directly (guarded by
DOCKER_GID) and drivesdocker composeto boot, stop, and ingest the engine services.
Tech stack
| Layer | Pick |
|---|---|
| App framework | Phoenix 1.8 (Elixir) |
| Web server | Bandit |
| Frontend | Phoenix LiveView + MapLibre GL JS |
| Styling | Tailwind CSS + DaisyUI |
| Asset bundling | esbuild + tailwind (Phoenix hex wrappers — no Node build step) |
| ORM | Ecto (ecto_sql + ecto_sqlite3) |
| Default DB | SQLite |
| Optional DB | PostgreSQL (DATABASE_URL=postgres://…) |
| Background jobs | Oban |
| Caching | Cachex |
| HTTP client | Req |
| API spec | OpenApiSpex (/api/v1 OpenAPI) |
| Auth | HTTP Basic (ADMIN_USERNAME / ADMIN_PASSWORD) |
| Tests | ExUnit (+ Mox, Bypass) |
| Control plane | In-app (Atlas.Control.*) — execs docker compose via the host Docker socket |
| Edge | Caddy (TLS + static tiles + reverse proxy) |
| Map services | Photon, Placeholder, libpostal, Valhalla, Overpass, OpenTripPlanner |
Topology
┌────────────────────────┐
│ Browser (MapLibre) │
└───────────┬────────────┘
│
▼
┌──────────────┐
│ Caddy │ TLS + /tiles/* static
└───┬────────┬─┘
│ │
/tiles/* │ │ /*
▼ ▼
┌────────┐ ┌──────────────────┐ ┌──────────────────┐
│ PMTiles│ │ Dawarich Atlas │ ─exec─→ │ host Docker │
│ static │ │ (Phoenix: API + │ compose │ daemon │
│ │ │ admin UI + │ │ (/var/run/ │
│ │ │ control plane) │ │ docker.sock) │
└────────┘ └────────┬─────────┘ └──────────────────┘
│
fan-out (internal Docker network only):
│
┌────────┬─────────────┬────────────┼────────────┬────────┐
▼ ▼ ▼ ▼ ▼ ▼
photon placeholder libpostal valhalla overpass otp
Only Caddy is published on host port 8484. Every other service is reachable only on the internal Docker network.
Layers
| # | Layer | Component | Data source |
|---|---|---|---|
| 1 | Base map tiles | Protomaps PMTiles | OSM planet (Protomaps daily build or self-built via Planetiler) |
| 2 | Tile serving | Caddy static + range + CORS | n/a |
| 3 | Map style | protomaps-themes-base (5 themes) | github.com/protomaps/basemaps |
| 4 | Geocoding | Photon + Placeholder + libpostal | OSM + Who's on First |
| 5 | Routing | Valhalla | OSM PBF + SRTM DEM |
| 6 | Elevation | Bundled with Valhalla | SRTM 1-arcsec |
| 7 | Terrain / hillshade (planned) | Terrain-RGB raster tiles | SRTM → rio-rgbify → PMTiles |
| 8 | POI lookup | Overpass API self-hosted | OSM PBF |
| 9 | Transit | OpenTripPlanner 2 | GTFS + OSM |
Why these engines
| Pick | Reasoning |
|---|---|
| Photon over Nominatim | ~70 GB prebuilt index vs Nominatim's ~1 TB Postgres. Same OSM source, much smaller box. |
| Placeholder | Resolves admin-hierarchy (country / state / city) when Photon's OSM tags are thin. |
| libpostal | Statistical address parser. Normalises long structured queries before they hit Photon. |
| Valhalla over OSRM | Multimodal in one binary, built-in elevation, MIT license, ferry/toll/highway avoidance. |
| Overpass | Tag-aware queries over OSM. Self-updates from minute diffs. |
| OpenTripPlanner 2 | The reference open-source multimodal planner. GTFS + OSM in a single graph. |
| MapLibre GL JS | The open fork of mapbox-gl. Reads PMTiles natively. |
| Caddy | Auto-HTTPS, range requests + compression for *.pmtiles, ergonomic config. |
App layer
Dawarich Atlas's job is to orchestrate: it exposes a clean /api/v1/* surface, fans out to the right engines, normalises shape, and degrades gracefully when an upstream is down. The internal modules are thin:
Atlas.Maps.Upstream.{Photon, Valhalla, Overpass, Otp, Placeholder, Libpostal}— direct HTTP wrappers over a sharedAtlas.Maps.Upstream.Client(Req).Atlas.Maps.{Search, Reverse, Geocode, Route, Transit, Poi, WhatsHere}— composition (Photon + Placeholder + cache + grid-snap), normalising every result to the canonicalAtlas.Maps.Place.Atlas.Control.*— the in-app control plane that drivesdocker composeagainst the host Docker socket (boot/stop services, region downloads, osmium merge, tiles).
The map UI is Phoenix LiveView with a MapLibre JS hook. Admin operations stream to the browser over the LiveView WebSocket.
The control plane
The admin UI's "boot a service / switch region / download tiles" actions don't call out to a separate process — they run inside the Phoenix app. The Atlas.Control.* modules reach the host Docker socket (/var/run/docker.sock, mounted into the app container) and exec docker compose against the host daemon directly.
- Privilege model. The app container is the only thing with the socket. It's reached as the
nobodyuser via theDOCKER_GIDsupplementary group — set it to the socket's gid (stat -c %g /var/run/docker.sock, often999;0on macOS). Mounting the daemon socket grants container-spawning power, so the app is the trust boundary: keep the admin panel behindADMIN_USERNAME/ADMIN_PASSWORDand off the public internet. - Operational surface. The control plane boots / stops profiles (
docker compose --profile geocoding up -d), tails service logs (streamed to the LiveView), downloads region PBFs, runs the osmium merge, stages OTP graphs, fetches PMTiles basemaps, and reports status back to the admin LiveViews. - Native ingest. Region data (PBF download, osmium merge, OTP staging) is processed natively under
/work/data— no separate worker image.
What the control plane does per admin action:
| Admin action | Control-plane work |
|---|---|
| Toggle a service | docker compose --profile <X> up -d <name> or … down <name> |
| View service logs | tail container logs, stream to the LiveView |
| Apply region | apply region change + start the projected service set |
| Switch region | swap regional bind-mount data + signal dependent services |
| Download tiles | pull a PMTiles file (Protomaps daily, custom URL, …) into data/tiles/ |
| Tiles status | report state of the local basemap file (exists, size, freshness) |
In-container docker compose targets the same project via COMPOSE_FILE=/work/compose.yml, and HOST_PROJECT_DIR lets it resolve the file's relative bind paths (./data/photon, …) against the host checkout — the daemon only understands host paths.
Architectural decision: Nominatim dropped
The geocoding stack originally included Nominatim for structured address hierarchies. Dropped in favor of:
- Photon (prebuilt index from Komoot, ~70 GB planet vs Nominatim's ~1 TB Postgres) — handles forward autocomplete and reverse geocoding.
- Placeholder — handles admin-hierarchy queries (
country / region / city) via Who's on First. - libpostal — handles query normalisation.
Tradeoff: lose Nominatim's fine-grained structured-address API (address.house_number, address.road, etc.). Photon still returns these as OSM tags, just less polished. Nominatim can be added back later without disturbing the rest.