The Route That Nearly Killed Me
Not literally. But Strava’s “fastest route” feature sent me down a four-lane arterial with no shoulder, three construction zones, and a bridge with a lane width that made me question every decision I’d ever made. Google Maps, when asked for “cycling directions,” gave me a road that cyclists are legally prohibited from using in my state. Neither app had any concept of what a bike actually wants.
This is not a niche problem. Every cyclist who has used a mainstream routing app has a version of this story. The apps are not wrong, exactly — they’re just solving for minimum time in a model that doesn’t understand that “minimum time” on a bike involves not dying. They don’t know that a gravel surface adds 20% rolling resistance. They don’t know that a 12% grade is a 10-minute slog for most humans on a loaded bike. They don’t know that a “quiet residential street” with car-dooring risk is worse than the slightly longer protected path one block over.
BRouter knows all of that. It’s a Java-based bicycle routing engine that uses OpenStreetMap segment data and a scoring DSL to model exactly these tradeoffs. It’s not flashy, but the routes it produces are what an experienced cyclist would actually choose.
Full example: Compose + a custom commute profile at github.com/KingPin/sumguy-examples/tree/main/self-hosting/brouter-cycling-routing
What’s Actually Wrong With Car-Brained Routing
Google Maps has a cycling mode. Strava has route suggestions. Here’s what they have in common: they treat a bike like a slow car.
Car routing considers distance, speed limits, and turn complexity. When adapted for bikes, someone adds “avoid highways” and calls it done. What it doesn’t model:
Surface type. OSM tags every segment — asphalt, gravel, compacted, dirt, sand. A road bike on gravel loses 20% efficiency. Car-brained routers treat all passable surfaces identically.
Elevation gradient. Standard routing treats climbing as a rough afterthought. Real cycling routing needs the gradient of each segment — 200m spread over 10km versus a 400m wall at the end are completely different rides.
Traffic exposure. A “bike-permitted” road where cars do 70 mph with no shoulder isn’t a cycling route. It’s a dare. Good routing weights these heavily or removes them from contention entirely.
Cycleway type. OSM distinguishes cycleway=track (separated), cycleway=lane (painted), cycleway=shared (symbols only). These are not the same experience and different riders have opposite preferences.
BRouter models all of this through configurable profile files. The core insight: routing quality is a function of how accurately your cost model reflects your preferences.
BRouter’s Architecture: Routing Engine, Not Navigation App
Before you get disappointed that it doesn’t have turn-by-turn voice guidance, understand what BRouter is: a routing engine. It takes a start point, an end point, and a profile, and returns a route as a GPX/GeoJSON file. What you do with that route is up to you.
The system has three pieces:
Segment data — BRouter downloads OSM data pre-sliced into 5°×5° geographic tiles (called “segments”). Each tile is a compact binary file containing all OSM way data and elevation information for that region. You download only the tiles you actually ride in. These live on disk and get memory-mapped at query time — that’s why it’s fast.
Profile files — .brf files written in BRouter’s own expression language. They define how each road segment is scored: base cost, surface cost modifier, gradient penalty, allowed/forbidden way types. BRouter ships with profiles for trekking, fast road cycling, mountain biking, and safety-first commuting. You can write your own.
HTTP API — The daemon exposes a REST API on port 17777. Send it two lat/lon pairs plus a profile name and it returns a route. That’s the integration point for everything else: OsmAnd, web frontends, scripts.
No user interface ships with the server itself. It’s infrastructure, not an app. That’s the right call — it means BRouter can serve whatever frontend you prefer, and its job is just to route well.
Docker Setup
The community image is nrenner/brouter. Start with this Compose file:
services: brouter: image: nrenner/brouter:1.7.5 container_name: brouter ports: - "17777:17777" volumes: - brouter-segments:/brouter/segments - ./profiles:/brouter/profiles environment: - JAVA_OPTS=-Xmx512m -Xms256m restart: unless-stopped
volumes: brouter-segments:The profiles/ directory is mounted from the host so you can edit profiles without rebuilding the container. The segments/ volume is where the geographic tiles live.
Bring it up:
docker compose up -ddocker compose logs -f brouterThe first start will fail with no segment data — that’s expected. You need to download tiles before routing works.
Downloading Segment Data
BRouter reads pre-built segment files from brouter.de — 5°×5° tiles named by their southwest corner. Download only what you need.
# Download tiles for your region (western US example)# Each tile is 20-150 MB depending on OSM densitydocker exec brouter wget -P /brouter/segments \ https://brouter.de/brouter/segments4/W120_N35.rd5 \ https://brouter.de/brouter/segments4/W115_N35.rd5 \ https://brouter.de/brouter/segments4/W110_N30.rd5Figure out which tiles cover your area using the BRouter tile index. The naming is the southwest corner lon/lat — W75_N40 covers the US Northeast.
Once you have tiles, verify the container is routing:
curl "http://localhost:17777/brouter?lonlats=-87.65,41.85|-87.63,41.87&profile=trekking&alternativeidx=0&format=geojson"GeoJSON back = working. Error about no segment data = wrong tiles.
The Profile DSL: How BRouter Actually Scores Routes
This is the interesting part. BRouter’s .brf profile format is a simple expression language that runs against each way segment’s OSM tags. The result is a cost value — lower cost means BRouter prefers that segment.
Here’s a simplified excerpt from trekking.brf to show the structure:
---context:way# assign initial cost based on highway typeassign initialcost = if highway=motorway then 10000 # effectively forbidden else if highway=trunk then 1000 else if highway=primary then 100 else if highway=secondary then 50 else if highway=tertiary then 30 else if highway=residential then 10 else if highway=path then 20 else if highway=cycleway then 0 # prefer dedicated infrastructure else 50
# surface penalty — applied on top of initialcostassign surfacecost = if surface=asphalt then 0 else if surface=paved then 0 else if surface=compacted then 10 else if surface=gravel then 20 else if surface=dirt then 40 else if surface=sand then 200 else 10
# elevation penalty per meter of climbingassign uphillcost = 60
# final cost per km of this segmentassign cost = add initialcost surfacecostThat’s the idea. Every segment gets a base cost from its highway= tag, a surface penalty from its surface= tag, and a climbing penalty proportional to gradient. The router finds the path that minimizes total cost — not total distance. A 1km cycleway on asphalt scores far cheaper than a 0.8km arterial on pavement.
The shipping profiles cover the main use cases:
trekking.brf— the general-purpose choice. Prefers cycleways and quiet roads, tolerates moderate climbing, handles mixed surfaces. Good for day rides and loaded touring.fastbike.brf— optimized for road cyclists who want the quickest route on a skinny-tire bike. Heavily penalizes unpaved surfaces, prefers fast roads even if they’re not ideal for cycling. Think “Strava segment hunter.”safety.brf— maximizes distance from cars. Will route you 30% out of the way to keep you on paths and quiet residential streets. Best for commuters in dense urban areas.mtb.brf— prefers paths, tracks, and off-road ways. Penalizes paved roads. For trail riders who want to string together off-road segments.
Writing a Custom Commute Profile
The shipping profiles are good starting points but real life is more specific. Here’s a practical custom profile for an urban commuter who wants to minimize car exposure without sacrificing too much time:
---context:globalassign mincostperklm = 1.40 # base cost floor per km
---context:way# Hard block on genuinely dangerous waysassign blocked = or highway=motorway or highway=motorway_link highway=trunk
# Base cost from infrastructure qualityassign basecost = if cycleway=track then 0 # fully separated — maximum preference else if cycleway=lane then 5 # painted lane, acceptable else if highway=cycleway then 0 # dedicated cycle path else if highway=path then 15 else if highway=residential then 20 else if highway=tertiary then 35 else if highway=secondary then 60 # heavy penalty, high traffic else if highway=primary then 100 else 40
# Surface matters less to a commuter (most urban streets are paved)# but still penalize obvious issuesassign surfacecost = if surface=asphalt then 0 else if surface=paved then 0 else if surface=cobblestone then 30 # hard on everything else if surface=gravel then 40 else 10
# Climbing penalty — commuters don't want to arrive sweatyassign uphillcost = 80
assign cost = add basecost surfacecostDrop this in your mounted profiles/ directory. Reference it in API calls as commute. No container restart needed — BRouter reloads profiles per request.
Querying the API
The HTTP API is simple. The main endpoint is /brouter:
# Route from one point to another using the commute profilecurl "http://localhost:17777/brouter?\lonlats=-87.6298,41.8781|-87.6168,41.8655\&profile=commute\&alternativeidx=0\&format=geojson" > route.geojson
# Get an alternative route (alternativeidx=1 gives the second-best option)curl "http://localhost:17777/brouter?\lonlats=-87.6298,41.8781|-87.6168,41.8655\&profile=commute\&alternativeidx=1\&format=gpx" > route_alt.gpxParameters worth knowing:
lonlats— pipe-separatedlon,latpairs. Add intermediate points as waypoints.profile—.brffilename without extension.alternativeidx—0for primary,1/2for meaningfully different alternatives.format—geojson,gpx, orkml. GPX for OsmAnd or Garmin.
The response includes elevation data and track stats (distance, climb, descent) in the properties. No post-processing needed.
OsmAnd Integration: BRouter on Your Phone
OsmAnd (Android) has native BRouter support. Configure it under Settings → Navigation settings → Route parameters → Other → Routing engine. Select “BRouter (online)” — despite the label, it accepts a local IP — then enter http://YOUR_LAN_IP:17777 and a profile name.
From there, OsmAnd routes through your BRouter instance and renders on its own offline maps. BRouter’s routing quality, OsmAnd’s turn-by-turn voice guidance. That’s the combination that actually works.
One catch: your phone needs to reach the server. Same LAN works trivially. For routing while out on the road, either Tailscale the connection or pre-plan routes at home and export GPX before you leave — which is honestly the simpler workflow anyway.
The Elevation Math Nobody Talks About
Cyclists talk about “total elevation gain” but gradient distribution is what actually determines effort. Two routes both gaining 150m:
- Route 1: steady 3% grade over 5km — you grind through it, arrive a bit warm.
- Route 2: flat for 4km, then a 10–12% wall for 1km — you might walk it, you definitely arrive wrecked.
Most routing engines model these identically. 150m is 150m. BRouter’s uphillcost is applied per segment based on actual gradient. A 10% grade is penalized much more heavily than a 3% grade covering the same vertical meters. This is why BRouter sometimes picks a route that looks longer on the map — the climbing is distributed across grades that don’t destroy your legs.
Limitations Worth Knowing
BRouter is a routing engine, not a fitness app and not a navigation app. A few things it doesn’t do:
No live navigation. BRouter returns a route file. It doesn’t track your position or give turn-by-turn prompts. That’s OsmAnd’s job (or Komoot, or whatever frontend you prefer).
No real-time data. Road closures, temporary conditions, current traffic — none of that affects BRouter. It routes from static OSM data. For cycling infrastructure this matters less than for driving.
Segment data ages. BRouter tiles are pre-built OSM snapshots. Re-download tiles every few months to pick up new bike paths and infrastructure changes.
Profile tuning takes iteration. Write a profile, ride a route, adjust the weights based on what it got wrong. The DSL is simple but good balance between climb penalty, surface penalty, and road type takes a few rides to dial in.
The core value is narrow and real: better bicycle routing than anything car-centric.
One Container, Better Routes
One Docker image, a handful of geographic tile files, a profile that reflects how you actually ride. The container is lightweight — 512 MB heap handles most workloads. Segment tiles are the only meaningful storage cost, and you only download what you need.
Wire BRouter into OsmAnd, pre-download GPX tracks before rides, and stop arguing with your phone about whether that arterial is “really fine for bikes.”
Your 2 AM self won’t have to explain to the ER why you were on that road.
Related posts
- Nominatim: Self-Hosted Geocoding — self-hosted address lookup from OpenStreetMap
- OsmAnd Offline Maps Deep Dive — full offline navigation without phoning home
- Headscale: Self-Hosted Tailscale Control Plane — VPN to reach home services from anywhere