From “I Want Driving Directions” to “I Own the /route Endpoint”
I started where everyone starts: just use Google Maps Directions API. It’s easy, documented, works. Then a side project got slightly popular, the request count climbed, and I got familiar with the part of the Google Cloud console that shows you a bar chart going up while your wallet goes down.
The thing is, routing algorithms aren’t magic. Dijkstra’s algorithm is textbook computer science. The data behind most commercial routing APIs is OpenStreetMap, same data you can download yourself. The gap between “just use the API” and “run your own” is smaller than it looks — a few hours of prep time, some RAM, and a Compose file that fits on one screen.
OSRM (Open Source Routing Machine) is the routing engine that actually lives up to that promise. Sub-millisecond route calculations on precomputed graphs, proper turn restrictions, pedestrian and bicycle profiles, real /route and /table endpoints. And you own the whole thing.
Full example: Compose, prep scripts, and sample profiles at github.com/KingPin/sumguy-examples/tree/main/self-hosting/self-hosted-osrm-docker
Step Zero: Get Your OSM Data
OSRM is useless without an OSM extract to eat. If you’ve been following along with the self-hosted geocoding setup from earlier this month, you’ve already been through this dance — Geofabrik publishes regional extracts at download.geofabrik.de in .osm.pbf format, organized by continent, country, and subregion.
Pick the smallest extract that covers your use case. Here’s the rough size picture for driving profiles:
- A single US state (Texas, California) — 300 MB–900 MB PBF, a few GB prepared graph, fits on any box with 4+ GB RAM
- A country (Germany, USA) — 1–4 GB PBF, 10–30 GB prepared graph, needs 8–16 GB RAM depending on algorithm
- North America or Europe full — 10–25 GB PBF, 80–150 GB prepared graph, you need real hardware
For a home lab or a solo dev project, a state or small country is the sweet spot. Download it somewhere permanent:
mkdir -p /opt/osrm/datacd /opt/osrm/data
# Example: Texaswget https://download.geofabrik.de/north-america/us/texas-latest.osm.pbf
# Example: Germanywget https://download.geofabrik.de/europe/germany-latest.osm.pbfThe .osm.pbf is the raw input. OSRM can’t route from it directly — you have to cook it first.
The Four-Step Prep Pipeline
OSRM’s routing is fast because it does the hard graph work offline. You preprocess the map data into a routing graph, then the running server just does fast lookups on that graph. The preprocessing has four steps, and knowing what each one does saves you when something goes wrong.
1. Extract
docker run --rm -v /opt/osrm/data:/data \ ghcr.io/project-osrm/osrm-backend \ osrm-extract -p /opt/osrm-backend/profiles/car.lua /data/texas-latest.osm.pbfThis reads the PBF and produces .osrm files — a parsed graph representation that knows about roads, their access rules, and their speeds according to the Lua profile you specify. The -p flag points at a Lua profile file. car.lua is the default for driving. We’ll get to what’s in that file in a minute.
This step is CPU-bound and takes 5–30 minutes depending on region size and hardware. It produces a bunch of files alongside your PBF: texas-latest.osrm, texas-latest.osrm.ebg, texas-latest.osrm.enw, and a bunch more.
2. Partition
docker run --rm -v /opt/osrm/data:/data \ ghcr.io/project-osrm/osrm-backend \ osrm-partition /data/texas-latest.osrmPartition divides the road network into cells — nested geographic partitions that MLD (Multi-Level Dijkstra) uses to speed up routing. If you’re using Contraction Hierarchies instead (more on that shortly), you can skip this step. The MLD pipeline requires both partition and customize. CH skips both and goes straight to contract.
3. Customize (MLD path) or Contract (CH path)
MLD:
docker run --rm -v /opt/osrm/data:/data \ ghcr.io/project-osrm/osrm-backend \ osrm-customize /data/texas-latest.osrmCH:
docker run --rm -v /opt/osrm/data:/data \ ghcr.io/project-osrm/osrm-backend \ osrm-contract /data/texas-latest.osrmosrm-customize injects the speed and turn-penalty data from the Lua profile into the partitioned graph. This is the step that actually bakes speed limits, road type weights, and access rules into the routing graph.
osrm-contract (for CH) computes the contraction hierarchy — a heavily precomputed shortcut graph that collapses millions of routing decisions into a structure the server can search in microseconds.
4. Routed
Once preprocessing is done, the server itself is trivial. The graph is already computed; osrm-routed just serves it.
CH vs MLD: Which Algorithm Should You Pick
This is where most tutorials gloss over something important, and then you get bitten later.
Contraction Hierarchies (CH) precomputes shortcuts across the entire graph. Route queries are extremely fast — we’re talking single-digit milliseconds for typical routes. But the precomputation is total. If you change your Lua profile (different speed limits, access rules, penalties), you throw away osrm-contract’s output and start the contraction step over. For a large region, that’s 30–90 minutes of blocking compute every time you want to tweak something.
Multi-Level Dijkstra (MLD) separates the graph structure (partition, slow, one-time) from the metric data (customize, fast, re-runnable). If you change your speed model or access rules, you only re-run osrm-customize — which is dramatically faster than full re-contraction. The trade-off is that MLD queries are slightly slower than CH queries at runtime, though we’re still talking under 10ms for regional routes on modern hardware.
In practice:
| CH | MLD | |
|---|---|---|
| Query speed | ~1–3ms | ~5–10ms |
| Profile change cost | Full re-contract | Re-customize only |
| Memory use | Lower | Slightly higher |
| Best for | Stable profile, high QPS | Iterating on profiles |
If you’re running a production service with a locked-down profile and you need every last millisecond, use CH. If you’re experimenting with custom speed models or access rules for different vehicle types, use MLD. For a home lab, MLD is almost always the right call — you will want to tweak the profile.
The Compose File
Once the data is prepped, the Compose file is refreshingly simple. The heavy lifting is already on disk.
services: osrm: image: ghcr.io/project-osrm/osrm-backend:latest container_name: osrm ports: - "5000:5000" volumes: - /opt/osrm/data:/data command: > osrm-routed --algorithm mld /data/texas-latest.osrm restart: unless-stoppedFor CH, swap --algorithm mld to --algorithm ch. Everything else stays the same — the .osrm file extension is shared between both; the algorithm determines which auxiliary files get read.
A few options worth knowing:
--max-table-size 10000— increases the maximum matrix size for/tablerequests (distance/duration matrices for many-to-many routing). Default is 100, which is tiny.--max-matching-size 500— for map matching endpoints. Increase if you’re matching long GPS traces.--port 5000— you can change this if something’s already on 5000.
The container starts in seconds since it’s just reading the precomputed graph from disk.
Lua Profiles: Where the Routing Actually Gets Interesting
The .lua profile is what makes OSRM understand what your vehicle can do. OSRM ships three:
car.lua— driving profile, respects car access tags, speed limits, turn restrictionsbicycle.lua— cycling, respects cycleway/footway tags, surface preferencesfoot.lua— pedestrian, much more permissive, uses walkways and paths
The profiles live inside the container at /opt/osrm-backend/profiles/. You can extract them and customize:
docker run --rm ghcr.io/project-osrm/osrm-backend \ cat /opt/osrm-backend/profiles/car.lua > /opt/osrm/profiles/car.luaNow mount your custom profiles into the container during the extract step:
docker run --rm \ -v /opt/osrm/data:/data \ -v /opt/osrm/profiles:/profiles \ ghcr.io/project-osrm/osrm-backend \ osrm-extract -p /profiles/car.lua /data/texas-latest.osm.pbfHere’s a snippet showing how profiles control speed and access. The key tables to know:
-- Speed table for road type → km/hlocal speed_profile = { motorway = 90, motorway_link = 45, trunk = 85, trunk_link = 40, primary = 65, primary_link = 30, secondary = 55, secondary_link = 25, tertiary = 40, residential = 25, living_street = 10, service = 15, track = 5,}
-- Access tags: which values mean "yes, can drive here"local access_tag_whitelist = Set { "yes", "motorcar", "motor_vehicle", "vehicle", "permissive", "designated", "destination",}Want to model a delivery van that can’t use residential streets? Drop residential from speed_profile. Want a profile for heavy trucks that avoids low bridges? OSM has maxheight and maxweight tags — the profile can read them. The Lua API gives you access to every tag on every way and node in the dataset.
Every profile change requires re-running osrm-extract plus osrm-customize (MLD) or osrm-contract (CH). There’s no hot-reload. Plan for this.
Making a Real /route Request
Once the container is up:
curl "http://localhost:5000/route/v1/driving/-97.7431,30.2672;-96.7970,32.7767?overview=full&steps=true"That routes from Austin, TX to Dallas, TX. The response shape looks like this (abbreviated):
{ "code": "Ok", "routes": [ { "distance": 305412.4, "duration": 10287.6, "geometry": "encodedPolyline...", "legs": [ { "distance": 305412.4, "duration": 10287.6, "steps": [ { "distance": 1203.4, "duration": 72.2, "name": "Congress Avenue", "maneuver": { "type": "depart", "modifier": "right", "bearing_after": 355 }, "driving_side": "right", "intersections": [...] } ] } ] } ], "waypoints": [ { "name": "Congress Avenue", "location": [-97.7431, 30.2672] }, { "name": "Main Street", "location": [-96.7970, 32.7767] } ]}distance is in meters, duration in seconds. The geometry is an encoded polyline (Google’s format — most mapping libraries decode it natively). steps are the turn-by-turn instructions. The endpoint accepts up to 25 waypoints per request by default.
Useful endpoint variations:
/route/v1/driving/— the one above, A-to-B (and multi-stop)/table/v1/driving/— duration/distance matrix for many-to-many/nearest/v1/driving/— snap a coordinate to the nearest road/match/v1/driving/— map-match a GPS trace to the road network/trip/v1/driving/— traveling salesman optimizer for a set of waypoints
Memory Requirements
The precomputed graph lives entirely in RAM (or in mmap’d files, which still need RAM to be useful). Rough numbers for the MLD algorithm on a car profile:
- A single US state (Texas, California) — 1–3 GB RAM
- Germany or France — 3–5 GB RAM
- Full USA — 10–14 GB RAM
- Full North America — 14–20 GB RAM
A 4 GB box handles most single-country or single-state use cases without breaking a sweat. If you’re running Texas car routing alongside a Nominatim geocoder on the same server, budget 4 GB for OSRM and another 3–4 GB for Nominatim, plus headroom for the OS. An 8–12 GB box handles both comfortably.
CH is slightly more memory-efficient than MLD because the shortcut graph is more compact, but the difference at regional scale is usually under 15%. Don’t choose your algorithm based on memory alone.
”But It’s Slower Than Google”
This comes up every time. The honest answer: for typical routing queries on a reasonably modern box, you’ll see end-to-end response times in the low-to-mid milliseconds — a bit more if the graph is large or the box is under load. Google is in the 100–300ms range for a round-trip API call, because that round-trip includes the internet.
If you’re calling OSRM on the same LAN, you’re almost certainly faster end-to-end than a commercial API. If you’re calling it from another continent on a slow VPS, you’re not. The latency is network-dominated once the routing itself takes under 10ms.
What OSRM legitimately doesn’t do as well as commercial APIs:
- Real-time traffic. OSRM uses static speed profiles. You can feed it custom speed data via the
/customizeendpoint (MLD only), but you need to build the pipeline to collect and apply that data yourself. - Live incidents. Road closures, construction — OSM data lags real-world events by hours to days.
- Turn-by-turn voice instructions. You get maneuver data. Human-readable instruction text is on you or a separate library like OSRM Text Instructions.
For anything where real-time conditions don’t matter — batch geocoding-adjacent work, logistics optimization, “find the closest five locations from this list,” delivery zone drawing — OSRM is genuinely better. Faster per-query, no per-request cost, no API key to rotate when it leaks.
Things That Will Bite You
- CH is not incremental. If your profile needs weekly tweaks, you will hate your life on large datasets. Start with MLD.
- Extract is profile-specific. You can’t extract once and use different profiles. Each profile needs its own extract.
- The
.osm.pbfand the prepared graph diverge. If the OSM data changes and you download a new PBF, you’re doing the full prep pipeline again. There’s no incremental update path for the routing graph (unlike Nominatim’s replication). - Port 5000 is the default — Flask also uses 5000. Change one of them before they collide and you spend 45 minutes confused.
- Memory overcommit. If the system starts swapping during a route query, response times go from 5ms to 5 seconds. Size your box to fit the graph in RAM with headroom.
- Multiple profiles = multiple graph files = multiple containers. If you need car + bicycle + foot routing, you run three prep pipelines and three containers. There’s no multi-profile server mode.
Wrapping Up
One wget, four docker commands for the prep pipeline, one Compose file, and you have your own routing engine. No API key, no billing dashboard, no third party watching where your users are going.
The MLD vs CH choice is the one decision that matters most early on — pick MLD unless you know you have a stable profile and you need the extra query throughput. The osrm-customize step being fast is the whole reason MLD exists, and you will change your profile.
If you want the full self-hosted maps stack — geocoding plus routing plus tile serving — the Nominatim geocoding post from earlier this month is the natural companion to this one. Same OSM data, complementary services, same kind of “just run it yourself” energy.
Your 2 AM self will appreciate not having to explain a surprise routing API invoice to anyone.
Related posts
- Nominatim: Self-Hosted Geocoding — address lookup without the API tax
- Nominatim vs Photon vs Pelias — which geocoder for which use case
- The Full Self-Hosted Maps Stack: Nominatim + PostGIS + Tiles — combine geocoding, routing, and tile serving
- PostGIS for Self-Hosted Mapping — spatial database fundamentals