OSRMplus

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.pbf

1. 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.pbf
docker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
  osrm-partition /data/region.osrm
docker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
  osrm-customize /data/region.osrm

2. Compress (the only new step)

docker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
  crn compress /data/region.osrm

3. Run

docker run -p 5000:5000 -v "$PWD/data:/data" \
  -e OSRM_CRN_FILE=/data/region.crn \
  squadem/osrmplus:latest

That'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:latest

Running 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.

squadem/osrmplus:latestdocker run -p 5000:5000
OSRMPlus Gateway:5000
/vrp/tsp/optimize/route/table/nearest/match/trip

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.

StepCommandOutputNew?
Extractosrm-extract.osm.pbf → .osrmNo
Partitionosrm-partition.osrmNo
Customizeosrm-customize.osrm (MLD)No
Compresscrn compress.crnYes
Servesquadem/osrmplus:latestPort 5000Yes

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.

EndpointMethodPurpose
/route/v1/driving/{ coords }GETPoint-to-point routing with geometry, steps, duration
/table/v1/driving/{ coords }GETN x N distance/duration matrix
/nearest/v1/driving/{ coords }GETSnap to nearest road segment
/match/v1/driving/{ coords }GETMap-match GPS traces to the road network
/trip/v1/driving/{ coords }GETRound-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
  }'
FieldTypeRequiredDescription
coordinates[lat, lon][]Yes*Stop locations. Not needed if you pass a matrix.
distance_matrixnumber[][]NoYour own cost matrix. Skips the internal /table call.
num_vehiclesintegerYesNumber of vehicles. Must be at least 1.
depotintegerNoIndex of the depot in the coordinates array. Default: 0.
demandsinteger[]NoLoad demand per stop. Set depot to 0.
vehicle_capacitiesinteger[]NoMax capacity per vehicle.
time_windows[start, end][]NoTime windows per stop, in seconds.
time_limit_secondsnumberNoHow long the solver can run. Default: 30.
allow_dropping_visitsbooleanNoLet the solver skip stops it can't fit.
drop_penaltynumberNoCost of dropping a visit. Higher means less likely to drop.
strategystringNoFirst solution strategy. Default: path_cheapest_arc. An unknown name is rejected rather than ignored.
objectivestringNominimize_total_distance, minimize_longest_route, minimize_total_time, or minimize_vehicles_used.
optimize_forstringNoWhich matrix drives the solve: duration (default) or distance.
detailed_solutionbooleanNoReturn 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
  }'
FieldTypeRequiredDescription
coordinates[lat, lon][]Yes*Stop locations.
distance_matrixnumber[][]NoYour own cost matrix.
depotintegerNoWhere the tour starts and ends. Default: 0.
strategystringNoFirst solution strategy. Default: path_cheapest_arc.
time_limit_secondsnumberNoHow 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

VariableDefaultDescription
OSRMPLUS_PROFILEoptimizationoptimization runs the gateway with the VRP and TSP solvers. routing serves the OSRM endpoints only.
OSRMPLUS_PORT5000Port the gateway listens on.
OSRMPLUS_DATASET/data/region.osrmThe .osrm graph to serve.
OSRMPLUS_OSRM_THREADS2Threads for the internal osrm-routed process.
OSRMPLUS_MAX_TABLE_SIZE10000Largest /table request the engine will accept. Caps matrix size for VRP and TSP.
OSRMPLUS_VRP_WORKERS2Solver processes for VRP. Each one blocks for a whole solve, so this is your concurrency.
OSRMPLUS_TSP_WORKERS2Same, for TSP.
OSRMPLUS_MAX_COORDINATES5000Upper 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

VariableDefaultDescription
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:latest

The 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.

ProfileTierBest For
routingUndirected pagingMostly /route traffic. Memory pages freely across all data.
optimizationOSRM_CRN_TIER=searchMostly /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:latest

Routing only, no solvers

docker run -p 5000:5000 -v "$PWD/data:/data" \
  -e OSRMPLUS_PROFILE=routing \
  squadem/osrmplus:latest

Memory 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 CapRAM SavedRoute LatencySlowdown
768 MB50%8.87 ms1.3x
448 MB (knee)71%10.00 ms1.4x
384 MB75%11.47 ms1.7x
240 MB84%79.78 ms11.5x
144 MB (floor)91%155.77 ms23x

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:latest

Docker

Health Check

curl http://localhost:5000/health

Returns 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.osrm

Re-compress

docker run --rm -v "$PWD/data:/data" squadem/osrmplus:latest \
  crn compress /data/region.osrm

Restart

docker restart osrmplus