Serving OpenStreetMap routing for the entire planet from home hardware sounds absurd until you realize most of the work happens once, offline, on machines with fast NVMe and enough RAM to survive the scratch-file storm. The Raspberry Pi’s job is simpler: load a pre-built tile archive and answer HTTP requests.
This post documents how I run Valhalla for the whole planet on a Pi 5, what failed along the way, and the counter-intuitive I/O lesson that saved hours of build time.
The shape of the problem
Valhalla builds routing graphs as a global tile grid. Each tile is a .gph file. A planet build touches scratch files (way_nodes.bin alone can exceed 120 GB) that dwarf available RAM. On a Ryzen 9950X with 60 GB RAM and 32 build threads, I watched the process enter a death spiral: 67% iowait, 7 GB swapped to zram, tile rate collapsing to ~180 tiles/hour.
The Pi cannot build the planet. It can serve it — if you ship it a merged tile tarball (~95 GB) and keep memory limits sane in valhalla.json.
Architecture: no runtime sharding
An early plan used three Valhalla containers behind an nginx geo-router. That worked in theory but added operational complexity. The production design is cleaner:
- Build Americas+Oceania on one x86 host.
- Build Eurasia+Africa on another.
- Merge at the tile-file level — copy both tile trees into one directory; on boundary overlaps, keep the larger
.gph(more detail wins). - Tar and deploy a single
valhalla_tiles.tarto the Pi. - One container, one endpoint.
Tile IDs are global. Two regional builds covering disjoint areas compose into a valid planet set without a runtime router.
flowchart LR
subgraph build["x86 build hosts"]
PBF["planet-latest.osm.pbf"]
EA["Eurasia+Africa tiles"]
AO["Americas+Oceania tiles"]
PBF --> EA
PBF --> AO
end
MERGE["merge-tiles.py\nlarger .gph wins"]
TAR["valhalla_tiles.tar\n~95 GB"]
PI["Raspberry Pi 5\nvalhalla-scripted:3.7.0"]
EA --> MERGE
AO --> MERGE
MERGE --> TAR
TAR --> PI
Public traffic hits TLS on a Pi 4B front proxy; the actual Valhalla container runs on the Pi 5. Same tile archive, different roles.
The build pipeline that survived contact with reality
The canonical pipeline uses osmium extract from a single planet PBF per region — never osmium merge of separate Geofabrik downloads. One source file keeps topology consistent across regional cuts.
valhalla-build.sh runs seven checkpointed phases:
| Phase | What | Threads |
|---|---|---|
| parse_ways | Initialize scratch (way_nodes.bin, ways.bin) |
16 |
| io_split | Move way_nodes.bin to a second NVMe |
— |
| parse_tail | Relations, nodes, edges (random reads) | 16 |
| build | Tile generation (I/O bound) | 4 |
| enhance | Hierarchy, shortcuts, restrictions | 16 |
| elevation | Bake SRTM into edges | 2 |
| validate | Spatial index restore | 16 |
Checkpoints mean a failed phase resumes without redoing completed work.
Thread count is the dominant knob
Dropping build threads from 32 to 4 eliminated the thrashing spiral. At 32 threads, most workers sat in uninterruptible sleep waiting on NVMe queue depth. At 4, the drive could actually finish requests.
This sounds slow. It is not — because the alternative is effectively stalled.
I/O split reverses at planet scale
A Czech micro-benchmark (1.7 GB scratch) showed zero benefit from splitting way_nodes.bin onto a second drive at 4 threads. At Eurasia+Africa scale (188 GB scratch, 3× RAM), the same trick delivered +41.8% on the build stage:
| Layout | Build time |
|---|---|
| 4 threads, single drive | 13h 07m |
| 4 threads, I/O split | 7h 38m |
The split run had more page faults, not fewer — but two NVMe controllers serve them in parallel. Micro-benchmarks lie when the bottleneck shifts from CPU cache contention to drive queue depth.
Rule of thumb: parse at full threads, build at 4, put way_nodes.bin on your fastest drive, split it to a second physical NVMe before the random-read phases.
Serving on the Pi
The Pi 5 runs ghcr.io/valhalla/valhalla-scripted:3.7.0 (native arm64). Tiles load from valhalla_tiles.tar via tile_extract — no rebuild in the container.
Memory tuning matters on 8 GB-class hardware. I reduced Thor label reservations and matrix limits in valhalla.json so concurrent route requests do not OOM the box.
Verification is scripted: known regression cases plus continental sanity routes (NYC↔LA, Berlin↔Tokyo, Sydney↔Melbourne, etc.). Deploy only passes canary on a spare instance before swapping production tiles.
Elevation: two different things
This trips people up:
- Route elevation — baked into tiles during the build’s
elevationstage. Global. Sydney→Melbourne returns tens of thousands of elevation samples in the route JSON. /heightendpoint — live SRTM lookup at arbitrary points. Requires a separateelevation_data/directory on disk (~200 GB for planet coverage).
I had routing elevation globally but /height returning null outside Europe because only a 40 GB regional SRTM subset was on the Pi. Fix: rsync the full dataset from the build host. No tile rebuild, no container restart — Valhalla reads SRTM tiles on demand.
Should you do this?
Do it if you want a private, global routing API and already have:
- Two x86 machines with fast NVMe (or patience),
- ~200 GB scratch space per regional build,
- A Pi with ≥1 TB storage for the tile tar plus headroom,
- Time for a multi-day build pipeline.
Do not do it if you just need routing for one country — use Geofabrik extracts and a single-machine build. Planet scale is a research project that happens to serve HTTP.
Resources
The orchestration scripts, merge tool, env templates, and benchmark write-ups live in a private repo for now. A sanitized public release may follow; the ideas above are the portable part.
- Valhalla building docs
- valhalla-scripted container
- Live endpoint (my deployment): valhal1.planetdg.com
Self-hosting at planet scale is mostly about refusing to run 32 threads against a 273 GB working set. The Pi is the easy part.