Skip to main content

Architecture

Dawarich Atlas is built around eight design principles:

  1. Local-first. Every layer runs on your hardware. No outbound API calls at runtime.
  2. Open data only. OSM, SRTM, GTFS — all freely downloadable.
  3. MapLibre GL as the renderer. Vector-first.
  4. PMTiles as the tile format. Static files, no tile server process.
  5. Minimal overlap, minimal resource consumption. Each service owns a distinct query type.
  6. Single compose file. One compose.yml, bind mounts under ./data/, region selected via .env.
  7. Caddy as the edge. Auto-HTTPS-ready, range requests + compression out of the box.
  8. 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 drives docker compose to boot, stop, and ingest the engine services.

Tech stack

LayerPick
App frameworkPhoenix 1.8 (Elixir)
Web serverBandit
FrontendPhoenix LiveView + MapLibre GL JS
StylingTailwind CSS + DaisyUI
Asset bundlingesbuild + tailwind (Phoenix hex wrappers — no Node build step)
ORMEcto (ecto_sql + ecto_sqlite3)
Default DBSQLite
Optional DBPostgreSQL (DATABASE_URL=postgres://…)
Background jobsOban
CachingCachex
HTTP clientReq
API specOpenApiSpex (/api/v1 OpenAPI)
AuthHTTP Basic (ADMIN_USERNAME / ADMIN_PASSWORD)
TestsExUnit (+ Mox, Bypass)
Control planeIn-app (Atlas.Control.*) — execs docker compose via the host Docker socket
EdgeCaddy (TLS + static tiles + reverse proxy)
Map servicesPhoton, 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

#LayerComponentData source
1Base map tilesProtomaps PMTilesOSM planet (Protomaps daily build or self-built via Planetiler)
2Tile servingCaddy static + range + CORSn/a
3Map styleprotomaps-themes-base (5 themes)github.com/protomaps/basemaps
4GeocodingPhoton + Placeholder + libpostalOSM + Who's on First
5RoutingValhallaOSM PBF + SRTM DEM
6ElevationBundled with ValhallaSRTM 1-arcsec
7Terrain / hillshade (planned)Terrain-RGB raster tilesSRTM → rio-rgbify → PMTiles
8POI lookupOverpass API self-hostedOSM PBF
9TransitOpenTripPlanner 2GTFS + OSM

Why these engines

PickReasoning
Photon over Nominatim~70 GB prebuilt index vs Nominatim's ~1 TB Postgres. Same OSM source, much smaller box.
PlaceholderResolves admin-hierarchy (country / state / city) when Photon's OSM tags are thin.
libpostalStatistical address parser. Normalises long structured queries before they hit Photon.
Valhalla over OSRMMultimodal in one binary, built-in elevation, MIT license, ferry/toll/highway avoidance.
OverpassTag-aware queries over OSM. Self-updates from minute diffs.
OpenTripPlanner 2The reference open-source multimodal planner. GTFS + OSM in a single graph.
MapLibre GL JSThe open fork of mapbox-gl. Reads PMTiles natively.
CaddyAuto-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 shared Atlas.Maps.Upstream.Client (Req).
  • Atlas.Maps.{Search, Reverse, Geocode, Route, Transit, Poi, WhatsHere} — composition (Photon + Placeholder + cache + grid-snap), normalising every result to the canonical Atlas.Maps.Place.
  • Atlas.Control.* — the in-app control plane that drives docker compose against 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 nobody user via the DOCKER_GID supplementary group — set it to the socket's gid (stat -c %g /var/run/docker.sock, often 999; 0 on macOS). Mounting the daemon socket grants container-spawning power, so the app is the trust boundary: keep the admin panel behind ADMIN_USERNAME / ADMIN_PASSWORD and 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 actionControl-plane work
Toggle a servicedocker compose --profile <X> up -d <name> or … down <name>
View service logstail container logs, stream to the LiveView
Apply regionapply region change + start the projected service set
Switch regionswap regional bind-mount data + signal dependent services
Download tilespull a PMTiles file (Protomaps daily, custom URL, …) into data/tiles/
Tiles statusreport 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.