Quick Start
You need Docker and a .osm.pbf file from Geofabrik. Five minutes to get everything running.
Want an AI agent to do the setup for you? Copy this prompt and give it to your assistant. It covers the full pipeline from installing Docker to verifying the health check.
Before you start, create a folder called data and put your .osm.pbf file inside it. All the commands below expect that folder to be there.
0. Put your download where the commands expect it
mkdir -p data && mv africa-latest.osm.pbf data/region.osm.pbf1. Prepare your map (your existing OSRM pipeline)
docker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
osrm-extract -p /opt/car.lua /data/region.osm.pbfdocker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
osrm-partition /data/region.osrmdocker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
osrm-customize /data/region.osrm2. Compress (the only new step)
docker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
crn compress /data/region.osrm3. Run
docker run -p 5000:5000 -v "$PWD/data:/data" \
-e OSRM_CRN_FILE=/data/region.crn \
squadem/osrmplus:latestThat's it. Port 5000 now serves the full OSRM API plus /vrp, /tsp, and /optimize. Your existing clients don't need any code changes.
Why everything is called region
The name region is just a default. When you start the container, it looks for /data/region.osrm automatically. If your files use that name, you do not need to set anything else.
You are free to use any name you like. For example, if your file is called africa.osrm, pass two extra settings when you start the container:
docker run -p 5000:5000 -v "$PWD/data:/data" \
-e OSRMPLUS_DATASET=/data/africa.osrm \
-e OSRM_CRN_FILE=/data/africa.crn \
squadem/osrmplus:latestRunning more than one map
Each container serves a single map. To serve multiple maps, start a separate container for each one on its own port, with its own data folder. A load balancer or simple proxy in front can then direct each request to the right container.
If you name every map file region, every container works with the same default settings. No extra configuration needed.
Keep in mind that separate containers cannot route across each other. A trip that starts in one map and ends in another will fail. If you need cross-border routing, combine the .osm.pbf files into one before running the extract step.
Large maps and memory
Building a map takes far more memory than serving one. The osrm-extract step loads the entire road network into RAM at once. A single city or a small country works on almost any machine. A full continent like Africa or Asia can require 100 GB or more.
If the extract step stops without printing an error, usually while showing Generating edge expanded nodes, the operating system ran out of memory and killed the process. Your data is not damaged. You have three options:
1. Use a smaller area. Download only the countries you need from Geofabrik instead of the full continent.
2. Add temporary swap space. Create a large swap file on the fastest disk available. The extract will finish, but it may take several hours. You can remove the swap file afterwards.
3. Build on a bigger machine, serve on a smaller one. Run the extract, partition, customize, and compress steps on a rented cloud server with enough RAM. Then copy the resulting .crn file to your production server. Serving uses only a fraction of the memory that building requires.
Architecture
One image, one exposed port, three processes inside managed by a supervisor.
osrm-routed
CRN compression
:5100
vrp_server
OR-Tools
:4081
tsp_server
OR-Tools
:4080
All internal · loopback only
Only the gateway is public. The routing engine and solvers run on loopback. If anything crashes, the supervisor restarts it.
Pipeline
OSRMPlus adds one step to the standard OSRM pipeline. Everything before it stays exactly the same. Same data, same profiles, same update schedule.
| Step | Command | Output | New? |
|---|---|---|---|
| Extract | osrm-extract | .osm.pbf → .osrm | No |
| Partition | osrm-partition | .osrm | No |
| Customize | osrm-customize | .osrm (MLD) | No |
| Compress | crn compress | .crn | Yes |
| Serve | squadem/osrmplus:latest | Port 5000 | Yes |
Your original .osrm files stay untouched. Want to go back to stock OSRM? Just switch the Docker image.
API Reference
Everything runs from one container on port 5000. The standard OSRM endpoints are proxied as-is, so existing clients keep working without any changes.
OSRM Passthrough
We follow the standard OSRM HTTP API. Responses are byte-identical to stock OSRM.
| Endpoint | Method | Purpose |
|---|---|---|
/route/v1/driving/{ coords } | GET | Point-to-point routing with geometry, steps, duration |
/table/v1/driving/{ coords } | GET | N x N distance/duration matrix |
/nearest/v1/driving/{ coords } | GET | Snap to nearest road segment |
/match/v1/driving/{ coords } | GET | Map-match GPS traces to the road network |
/trip/v1/driving/{ coords } | GET | Round-trip (TSP) through waypoints |
Coordinates are lng,lat pairs separated by semicolons, like 46.6753,24.7136;46.7153,24.7536.
Route between two points
curl "http://localhost:5000/route/v1/driving/46.6753,24.7136;46.7153,24.7536?overview=full&steps=true"3x3 duration matrix
curl "http://localhost:5000/table/v1/driving/46.67,24.71;46.72,24.75;46.69,24.73"POST /vrp
Multi-vehicle fleet optimization with time windows, capacity constraints, and priority stops. OSRMPlus builds the distance matrix for you using /table, or you can pass your own precomputed matrix.
Coordinates here are [lat, lon] pairs. That is the opposite of the OSRM endpoints above, which take lon,lat in the URL.
curl -X POST http://localhost:5000/vrp \
-H "Content-Type: application/json" \
-d '{
"coordinates": [
[24.7136, 46.6753],
[24.7536, 46.7153],
[24.7336, 46.6953],
[24.7436, 46.7053]
],
"num_vehicles": 2,
"depot": 0,
"demands": [0, 10, 20, 15],
"vehicle_capacities": [30, 30],
"time_limit_seconds": 30,
"detailed_solution": true
}'| Field | Type | Required | Description |
|---|---|---|---|
coordinates | [lat, lon][] | Yes* | Stop locations. Not needed if you pass a matrix. |
distance_matrix | number[][] | No | Your own cost matrix. Skips the internal /table call. |
num_vehicles | integer | Yes | Number of vehicles. Must be at least 1. |
depot | integer | No | Index of the depot in the coordinates array. Default: 0. |
demands | integer[] | No | Load demand per stop. Set depot to 0. |
vehicle_capacities | integer[] | No | Max capacity per vehicle. |
time_windows | [start, end][] | No | Time windows per stop, in seconds. |
time_limit_seconds | number | No | How long the solver can run. Default: 30. |
allow_dropping_visits | boolean | No | Let the solver skip stops it can't fit. |
drop_penalty | number | No | Cost of dropping a visit. Higher means less likely to drop. |
strategy | string | No | First solution strategy. Default: path_cheapest_arc. An unknown name is rejected rather than ignored. |
objective | string | No | minimize_total_distance, minimize_longest_route, minimize_total_time, or minimize_vehicles_used. |
optimize_for | string | No | Which matrix drives the solve: duration (default) or distance. |
detailed_solution | boolean | No | Return per-stop demand, cumulative load, and arrival time instead of bare node ids. |
Response
{
"job_id": "b987312c-e52b-4152-b137-784852d14cf9",
"status": "SUCCESS",
"objective": 3571,
"routes": [
{
"vehicle_id": 0,
"route": [
{ "node": 0, "demand": 0, "cumulative_load": 0 },
{ "node": 3, "demand": 4, "cumulative_load": 4 },
{ "node": 0 }
],
"distance": 898,
"load": 4
}
],
"total_distance": 3571,
"max_route_distance": 2673,
"dropped_nodes": [],
"statistics": {
"solve_time_ms": 1,
"num_locations": 5,
"num_vehicles": 2,
"vehicles_used": 2
},
"meta": {
"matrix": { "strategy": "single", "requests": 1, "cells": 25, "duration_ms": 22 },
"solver": { "solve_ms": 4, "status": "SUCCESS", "time_limit_seconds": 5 },
"unreachable_pairs": 0,
"optimize_for": "duration"
}
}Without detailed_solution, each route is a plain array of node indices. A vehicle the solver decided not to use comes back as [0, 0].
POST /tsp
Single-vehicle travelling salesman. Finds the shortest round-trip through all your stops.
curl -X POST http://localhost:5000/tsp \
-H "Content-Type: application/json" \
-d '{
"coordinates": [
[24.7136, 46.6753],
[24.7536, 46.7153],
[24.7336, 46.6953]
],
"depot": 0,
"time_limit_seconds": 10
}'| Field | Type | Required | Description |
|---|---|---|---|
coordinates | [lat, lon][] | Yes* | Stop locations. |
distance_matrix | number[][] | No | Your own cost matrix. |
depot | integer | No | Where the tour starts and ends. Default: 0. |
strategy | string | No | First solution strategy. Default: path_cheapest_arc. |
time_limit_seconds | number | No | How long the solver can run. Default: 30. |
Response
{
"job_id": "19dc40cf-b513-459e-b4f1-f79df85c88d6",
"status": "SUCCESS",
"objective": 2094,
"route": [0, 3, 2, 1, 0],
"total_distance": 2094,
"statistics": { "solve_time_ms": 1, "nodes": 4 },
"meta": {
"matrix": { "strategy": "single", "requests": 1, "cells": 16, "duration_ms": 22 },
"solver": { "solve_ms": 4, "status": "SUCCESS", "time_limit_seconds": 5 },
"unreachable_pairs": 0,
"optimize_for": "duration"
}
}POST /optimize
Same as /vrp but OSRMPlus picks the vehicle count and tunes the solver for you. Just pass your stops and constraints.
curl -X POST http://localhost:5000/optimize \
-H "Content-Type: application/json" \
-d '{
"coordinates": [
[24.7136, 46.6753],
[24.7536, 46.7153],
[24.7336, 46.6953]
],
"depot": 0
}'It accepts the same fields as /vrp. Leave num_vehicles out and OSRMPlus probes for the smallest fleet that can serve every stop, then reports what it chose in meta.solver.vehicle_selection. If you do send vehicle_capacities, you have to send num_vehicles too, since the solver needs one capacity per vehicle.
Errors
A malformed request comes back as HTTP 400 with an error object.
{
"error": {
"code": "invalid_request",
"message": "vehicle_capacities must have one entry per vehicle"
}
}A request that was valid but had no answer is different, and it catches people out: you get HTTP 200, a status that is not SUCCESS, and error as a plain string. Check status, not the HTTP code.
{
"job_id": "af6ca858-0688-4644-89e5-482f8f8088a3",
"status": "NO_SOLUTION_FOUND",
"error": "No solution found within time limit",
"statistics": { "solve_time_ms": 4, "num_locations": 8, "num_vehicles": 3 }
}Configuration
Everything is configured through environment variables. Pass them to docker run with -e.
Server
| Variable | Default | Description |
|---|---|---|
OSRMPLUS_PROFILE | optimization | optimization runs the gateway with the VRP and TSP solvers. routing serves the OSRM endpoints only. |
OSRMPLUS_PORT | 5000 | Port the gateway listens on. |
OSRMPLUS_DATASET | /data/region.osrm | The .osrm graph to serve. |
OSRMPLUS_OSRM_THREADS | 2 | Threads for the internal osrm-routed process. |
OSRMPLUS_MAX_TABLE_SIZE | 10000 | Largest /table request the engine will accept. Caps matrix size for VRP and TSP. |
OSRMPLUS_VRP_WORKERS | 2 | Solver processes for VRP. Each one blocks for a whole solve, so this is your concurrency. |
OSRMPLUS_TSP_WORKERS | 2 | Same, for TSP. |
OSRMPLUS_MAX_COORDINATES | 5000 | Upper bound on stops in one optimization request. |
OSRMPLUS_LICENCE_KEY | (unset) | Signed licence key. Without one you run on the free tier. |
Compression and memory
| Variable | Default | Description |
|---|---|---|
OSRM_CRN_FILE | (unset) | Path to the .crn file. Turns on CRN compression. Leave unset for stock OSRM. |
OSRM_CRN_TIER | (auto) | Pins search columns in memory. Set to search automatically when the profile is optimization and a .crn file is present. |
OSRM_CRN_HOT | (empty) | Extra column patterns to keep in memory, comma-separated. |
OSRM_CRN_COLD | (empty) | Column patterns to remove from the tier preset. |
OSRM_CRN_EXCLUDE | (empty) | Block names to skip CRN for and read raw OSRM data. Useful for debugging. |
docker run -p 5000:5000 -v "$PWD/data:/data" \
-e OSRM_CRN_FILE=/data/region.crn \
-e OSRM_CRN_TIER=search \
squadem/osrmplus:latestThe engine underneath
The container starts and supervises osrm-routed itself, always with --algorithm mld. Arguments you append to docker run are not passed through to it, so tune it with the environment variables above: OSRMPLUS_OSRM_THREADS becomes --threads and OSRMPLUS_MAX_TABLE_SIZE becomes --max-table-size.
Profiles
Two profiles ship. They differ in how memory is managed and whether the solvers run at all. optimization is the default.
| Profile | Tier | Best For |
|---|---|---|
| routing | Undirected paging | Mostly /route traffic. Memory pages freely across all data. |
| optimization | OSRM_CRN_TIER=search | Mostly /vrp and /table traffic. Keeps search data in memory so faults only hit response data. |
Optimization profile, the default
docker run -p 5000:5000 -v "$PWD/data:/data" \
-e OSRM_CRN_FILE=/data/region.crn \
squadem/osrmplus:latestRouting only, no solvers
docker run -p 5000:5000 -v "$PWD/data:/data" \
-e OSRMPLUS_PROFILE=routing \
squadem/osrmplus:latestMemory Tuning
CRN memory-maps its data, so pages get loaded on demand and dropped when the OS needs the space. The trade-off is simple: less RAM means more disk reads, which means higher latency.
Everything below is about serving a map. Building one is a separate, much hungrier job, covered in the quick start.
GCC Region (1,434 MB OSRM → 508 MB .crn)
| Container Cap | RAM Saved | Route Latency | Slowdown |
|---|---|---|---|
| 768 MB | 50% | 8.87 ms | 1.3x |
| 448 MB (knee) | 71% | 10.00 ms | 1.4x |
| 384 MB | 75% | 11.47 ms | 1.7x |
| 240 MB | 84% | 79.78 ms | 11.5x |
| 144 MB (floor) | 91% | 155.77 ms | 23x |
The sweet spot is around the .crn file size. Up to that point you get big RAM savings with barely any latency hit. Below it, things slow down fast.
Bigger maps actually handle low memory better. A single query touches about the same amount of data no matter how large the map is, so on planet-scale data, 90%+ can sit on disk without affecting most requests.
Thread Count
Each worker thread uses 2-8 MB of memory (depending on the biggest matrix it has served) and never gives it back. So your memory floor is roughly data + threads x heap. The default is 2 threads.
Cap container memory and set the engine thread count
docker run -p 5000:5000 --memory=512m \
-v "$PWD/data:/data" \
-e OSRM_CRN_FILE=/data/region.crn \
-e OSRMPLUS_OSRM_THREADS=2 \
squadem/osrmplus:latestDocker
Health Check
curl http://localhost:5000/healthReturns the status of the gateway, routing engine, and solver workers.
Refreshing Map Data
Download a fresh .osm.pbf, re-run the pipeline, and restart. Since OSRMPlus uses MLD, you can update traffic data with just osrm-customize without re-partitioning.
Update traffic data (no re-partition needed)
docker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
osrm-customize /data/region.osrmRe-compress
docker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
crn compress /data/region.osrmRestart
docker restart osrmplus