Containerizing the OSRM routing engine eliminates third-party API rate limits, keeps sensitive logistics data on-premise, and delivers sub-millisecond route responses for batch geospatial workloads. This page is part of the Python Routing Engines & Isochrone Mapping guide and covers the complete pipeline from raw OSM data to a production-ready HTTP daemon: downloading a PBF extract, preprocessing it into a directed graph, tuning the Docker Compose service, and validating endpoints programmatically. Whether you are building fleet dispatch systems, accessibility models, or custom cost functions for routing solvers, a locally hosted OSRM instance gives you full control over routing logic and data freshness.


OSRM Docker Deployment Pipeline Five-stage pipeline: OSM PBF extract, osrm-extract, osrm-partition, osrm-customize, and osrm-routed HTTP daemon, with Python client querying the endpoint. OSM PBF Geofabrik extract .osm.pbf osrm-extract Lua profile → .osrm.edges osrm-partition MLD hierarchy → .osrm.cells osrm-customize Edge weights → .osrm.mldgr osrm-routed HTTP daemon :5000/route/v1 Python client requests.get() OSRM Docker Deployment Pipeline Preprocessing runs once per PBF update; osrm-routed serves live traffic

Prerequisites

Confirm these resources before starting. The preprocessing steps are memory-intensive and will abort silently if swap is exhausted.

System requirements

Resource Minimum Recommended
Docker Engine 20.10 Latest stable
Docker Compose v2 v2.20+
RAM 8 GB 16 GB+ for regional extracts
Storage 2× PBF file size NVMe SSD, 3× PBF size for workspace
CPU 4 cores 8+ cores reduces partition time

Python environment

# Install client-side dependencies (Python 3.9+)
pip install requests geopandas pyproj shapely

OSM data source

Download a PBF extract from Geofabrik. Metropolitan-area extracts (100–400 MB) are ideal for development. Country-level extracts work in production but require proportionally more RAM for preprocessing.


Conceptual Architecture

OSRM achieves sub-millisecond routing through two algorithms built on a preprocessed directed graph derived from building directed graphs from OSM PBF files.

Contraction Hierarchies (CH) collapse the graph by adding shortcut edges between high-importance nodes, then query with a bidirectional Dijkstra that expands only the contracted hierarchy. This delivers the lowest possible single-route latency on a static graph but does not support dynamic weight updates.

Multi-Level Dijkstra (MLD) partitions the graph into nested cells, each with precomputed boundary weights. MLD is the preferred algorithm for production deployments because osrm-customize can re-apply new edge weights (for traffic overlays or time-of-day profiles) to an already-partitioned graph in minutes rather than hours. The integrating custom traffic weights into OSRM page covers the re-customization workflow in detail.

The three preprocessing binaries map to distinct build artifacts:

  • osrm-extract reads the PBF and applies a Lua profile to emit .osrm.edges, .osrm.nodes, .osrm.restrictions, and .osrm.turn_weight_penalties.
  • osrm-partition groups nodes into hierarchical cells, producing .osrm.partition and .osrm.cells.
  • osrm-customize walks the cell boundaries and stores final edge weights in .osrm.mldgr.

The Docker image bundles all three binaries alongside the standard Lua profiles (car.lua, bicycle.lua, foot.lua), so no local C++ toolchain is required.


Step-by-Step Implementation

1. Acquire the OSM PBF Extract

# Download a metropolitan-area extract for development
mkdir -p ~/osrm-data && cd ~/osrm-data
wget https://download.geofabrik.de/north-america/us/district-of-columbia-latest.osm.pbf

# Verify checksum (Geofabrik publishes .md5 alongside each file)
wget https://download.geofabrik.de/north-america/us/district-of-columbia-latest.osm.pbf.md5
md5sum -c district-of-columbia-latest.osm.pbf.md5

Routing accuracy depends on OSM tag completeness in the extract. Before preprocessing, use osmium command-line tools to inspect road network coverage and confirm that highway, maxspeed, and access tags are populated at the density your logistics profile requires.

2. Extract the Routing Graph

The car.lua profile maps OSM tags to directed edges, applies maxspeed values, and encodes oneway=yes restrictions. Run extraction inside the same image that will serve routes to guarantee binary compatibility:

docker run --rm -t \
  -v "$(pwd):/data" \
  osrm/osrm-backend \
  osrm-extract -p /opt/car.lua /data/district-of-columbia-latest.osm.pbf

Expected output artifacts: district-of-columbia-latest.osrm, .osrm.edges, .osrm.nodes, .osrm.restrictions, .osrm.turn_weight_penalties, .osrm.names, .osrm.geometry, .osrm.datasource_names.

Extraction time scales roughly linearly with network density. A 200 MB metro PBF takes 2–5 minutes; a 3 GB country PBF can take 45–90 minutes. Plan preprocessing windows accordingly when scheduling nightly OSM refreshes.

3. Partition the Graph

osrm-partition performs recursive bisection of the network into a multi-level cell structure. This step is CPU-bound and benefits from high core counts:

docker run --rm -t \
  -v "$(pwd):/data" \
  osrm/osrm-backend \
  osrm-partition /data/district-of-columbia-latest.osrm

New artifacts: .osrm.partition, .osrm.cells, .osrm.cell_metrics.

4. Customize Edge Weights

osrm-customize walks all cell boundary edges and computes the final weight matrix. This is the step you re-run when applying a traffic overlay without touching the partition:

docker run --rm -t \
  -v "$(pwd):/data" \
  osrm/osrm-backend \
  osrm-customize /data/district-of-columbia-latest.osrm

New artifact: .osrm.mldgr (the customized metric graph).

That separation between partition and customise is the practical reason to prefer MLD over CH for anything with live traffic. The partition is a function of topology alone, so it survives a weight change; only the customise step has to re-run, and it does so in a fraction of the time.

What a weight-only change costs under MLD and under CH Both pipelines share the extract and partition stages. Under multi-level Dijkstra only the customise stage re-runs when weights change, taking around forty seconds on a district extract. Under contraction hierarchies the whole contraction re-runs, taking around eleven minutes. Traffic arrives — which stages have to run again? MLD osrm-extract topology — unchanged osrm-partition topology — unchanged osrm-customize re-runs — about 40 s one stage CH osrm-extract topology — unchanged osrm-contract shortcuts depend on the weights — full re-run, about 11 min everything CH still wins on single-route latency, so the choice is a genuine trade: fastest queries, or weights you can refresh every few minutes. The rule of thumb is that live traffic and CH do not belong in the same deployment; run CH for a static baseline profile if you need both. Either way the algorithm flag on osrm-routed must match the artefacts on disk, or the daemon refuses to start.

5. Launch the Routing Daemon

docker run -d \
  --name osrm-local \
  -p 5000:5000 \
  -v "$(pwd):/data" \
  osrm/osrm-backend \
  osrm-routed \
    --algorithm mld \
    --max-table-size 1000 \
    --threads 4 \
  /data/district-of-columbia-latest.osrm

Flag reference:

  • --algorithm mld — required to use the MLD artifacts; omit if you preprocessed with CH.
  • --max-table-size 1000 — caps the distance matrix endpoint at 1000×1000 to prevent OOM under load.
  • --threads 4 — match to physical CPU cores for optimal query concurrency; hyperthreading yields diminishing returns here.

Confirm the daemon is ready:

docker logs osrm-local | grep "running and waiting for requests"
curl -sf http://localhost:5000/health && echo "healthy"

6. Validate Endpoints with Python

# requires: requests (pip install requests)
import requests
import json

def validate_osrm(host: str = "http://localhost:5000") -> dict:
    """Query the OSRM route endpoint and return key metrics."""
    # Coordinates for a short DC segment: [lon, lat]
    coords = "-77.0365,38.8977;-77.0091,38.8895"
    url = f"{host}/route/v1/driving/{coords}?geometries=geojson&overview=full&steps=false"

    resp = requests.get(url, timeout=5)
    resp.raise_for_status()
    data = resp.json()

    assert data["code"] == "Ok", f"Routing error: {data.get('message')}"
    route = data["routes"][0]

    print(f"Distance : {route['distance']:,.1f} m")
    print(f"Duration : {route['duration']:,.1f} s")
    print(f"Geometry : {route['geometry']['type']}")
    return route

route = validate_osrm()

For teams building graph-aware optimization layers, this REST response can be parsed into adjacency matrices or fed into NetworkX shortest path algorithms for logistics for VRP decomposition or constraint-based heuristics.


Configuration Reference

Key osrm-routed Flags

Flag Default Effect
--algorithm ch mld enables dynamic weight updates; ch gives lower single-route latency
--max-table-size 100 Maximum one-side dimension for /table requests; capped to prevent OOM
--threads 1 Worker threads for concurrent HTTP requests; set to physical core count
--max-viaroute-size 500 Maximum waypoints per /route request
--max-matching-size 100 Maximum GPS trace points per /match request
Disk footprint of the OSRM artefact chain Bars showing the size of each artefact produced for a district-sized extract: the source PBF at 18 megabytes, the extracted graph at 210, the partition and cell files at 96, and the customised metric graph at 148. The whole set must stay on the mounted volume, not just the last file. Every artefact stays on the volume — the daemon memory-maps the set, not one file source .osm.pbf 18 MB — the only input you re-download .osrm.* from extract 210 MB .partition, .cells 96 MB — survives a weight change .osrm.mldgr 148 MB — rewritten by every customise Budget roughly 25 to 30 times the PBF size for the full artefact set, and provision the volume for the peak during preprocessing. A blue-green swap needs two full sets on disk at once, so double the figure again if you refresh weights without downtime.

Lua Profile Tag Mapping (car.lua defaults)

OSM Tag Effect
highway=motorway High speed, no pedestrian access
highway=residential Low default speed (25 km/h)
oneway=yes Edge direction enforced; reverse direction dropped
access=private Edge excluded unless service=delivery override
maxspeed=* Overrides profile default; parsed from numeric and tagged values
turn:lanes=* Feeds turn restriction penalty logic

Customizing the profile for freight requires editing the speed tables and access logic. The configuring edge weights for freight logistics page details hgv, weight, and maxweight tag handling for heavy vehicle routing.


Production Optimization and Scaling

Sizing the host before you tune anything else

OSRM’s resource profile is unusual in that the expensive phase and the serving phase want different machines. Preprocessing is CPU- and I/O-bound and briefly needs several times the PBF size in scratch space; serving is almost entirely memory-resident and needs very little CPU per request once the tiles are warm. A country-scale extract that preprocesses comfortably on a sixteen-core box with fast NVMe will then serve happily from a much smaller instance, provided that instance has enough RAM to hold the prepared graph without touching swap.

The failure mode worth designing against is memory pressure rather than CPU saturation. When the prepared data no longer fits in RAM the kernel begins paging the memory-mapped tiles, and p99 latency degrades by one to two orders of magnitude while CPU utilisation still looks unremarkable — which sends people tuning thread counts when the actual fix is more memory or a smaller extract. Measure resident set size after the first hundred warm queries, not immediately after startup, because the mapped files are only faulted in as regions of the graph are actually touched. Build the preprocessing step as a separate job that publishes its output to object storage, and let the serving instances pull a prepared artefact rather than preprocessing in place; this decouples the two sizing problems and makes rollback a matter of pointing at the previous artefact.

Docker Compose for Persistent Deployments

Replace ad-hoc docker run commands with a declarative Compose file to enforce restart policies, resource limits, and volume isolation:

# docker-compose.yml
services:
  osrm:
    image: osrm/osrm-backend:latest
    container_name: osrm-prod
    restart: unless-stopped
    ports:
      - "5000:5000"
    volumes:
      - ./osrm-data:/data:ro
    mem_limit: 16g
    memswap_limit: 16g
    command: >
      osrm-routed
        --algorithm mld
        --threads 8
        --max-table-size 500
        /data/district-of-columbia-latest.osrm
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:5000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

Notes:

  • mem_limit and memswap_limit apply to standalone Compose; deploy.resources only takes effect in Swarm mode.
  • The :ro volume flag prevents accidental graph file modification by a rogue container process.
  • Set mem_limit to 1.2× the size of the preprocessed .osrm.* file set. The daemon memory-maps all files at startup.

Memory Sizing by Extract Scale

Extract Approximate RAM
Single metro area (city) 2–6 GB
US state 8–20 GB
Full USA 32–64 GB
Western Europe 64–128 GB

For large extracts consider filtering the PBF to the highway types relevant to your fleet before preprocessing. osmium tags-filter can reduce a country PBF by 40–60% by retaining only highway=motorway, highway=trunk, highway=primary, and highway=secondary.

Zero-Downtime Graph Updates

OSRM does not support hot-swapping graph files at runtime. The update sequence is:

  1. Preprocess new .osm.pbf into a parallel directory (osrm-data-next/).
  2. Start a second container (osrm-next) pointing at osrm-data-next/.
  3. Wait for the container health check to pass.
  4. Update the reverse proxy (Nginx or Traefik) upstream to point at osrm-next.
  5. Stop osrm-prod and promote osrm-next.

For teams running on cloud infrastructure, setting up OSRM on AWS EC2 with automated graph refresh covers EBS volume provisioning and the Instance Metadata Service pattern for IAM-authenticated S3 PBF downloads.


Validation and Testing

After deployment, run this validation matrix before routing traffic from production systems:

# requires: requests, geopandas, shapely (pip install requests geopandas shapely)
import requests
import geopandas as gpd
from shapely.geometry import LineString, shape

HOST = "http://localhost:5000"

def check_route_geometry(host: str) -> None:
    """Assert that a known short route returns a plausible LineString."""
    coords = "-77.0365,38.8977;-77.0091,38.8895"
    data = requests.get(
        f"{host}/route/v1/driving/{coords}?geometries=geojson&overview=full",
        timeout=5
    ).json()
    assert data["code"] == "Ok"
    geom = shape(data["routes"][0]["geometry"])
    assert isinstance(geom, LineString), "Expected LineString geometry"
    assert geom.length > 0, "Geometry has zero length"
    print(f"[PASS] Route geometry: {geom.length:.6f} degrees")

def check_table_endpoint(host: str) -> None:
    """Assert that a 3×3 distance matrix returns numeric durations."""
    coords = "-77.0365,38.8977;-77.0200,38.9000;-77.0091,38.8895"
    data = requests.get(
        f"{host}/table/v1/driving/{coords}?annotations=duration",
        timeout=10
    ).json()
    assert data["code"] == "Ok"
    durations = data["durations"]
    assert len(durations) == 3 and len(durations[0]) == 3
    assert durations[0][0] == 0.0, "Self-route duration should be 0"
    print(f"[PASS] Table endpoint 3×3: durations[0][1]={durations[0][1]:.1f}s")

def check_latency(host: str, max_ms: float = 50.0) -> None:
    """Assert sub-50 ms single-route latency (metro area, MLD algorithm)."""
    import time
    coords = "-77.0365,38.8977;-77.0091,38.8895"
    start = time.perf_counter()
    requests.get(f"{host}/route/v1/driving/{coords}", timeout=5)
    elapsed_ms = (time.perf_counter() - start) * 1000
    assert elapsed_ms < max_ms, f"Latency {elapsed_ms:.1f} ms exceeds {max_ms} ms budget"
    print(f"[PASS] Latency: {elapsed_ms:.1f} ms")

check_route_geometry(HOST)
check_table_endpoint(HOST)
check_latency(HOST)

Sanity metrics to monitor in production:

  • P99 /route latency should stay below 20 ms for metro-area graphs with MLD.
  • /table requests with more than 200 origins or destinations risk OOM; monitor container memory against mem_limit.
  • Log osrm-routed startup time after each graph update; an increase of more than 30% signals the graph grew unexpectedly.

Troubleshooting

std::bad_alloc during osrm-extract

The host has insufficient RAM or swap for the PBF size. Options in order of preference: (1) increase host RAM, (2) add swap (fallocate -l 8G /swapfile && mkswap /swapfile && swapon /swapfile), (3) reduce extract scope by filtering the PBF with osmium extract to a smaller bounding box, (4) pass --memory-swap to the docker run call to allow container swap.

No route found between coordinates

The two points lie on disconnected graph components. Common causes: (1) the points snap to pedestrian paths that car.lua excludes, (2) a water body or missing bridge creates a barrier, (3) access=private tags exclude the road class in use. Debug by querying /nearest/v1/driving/{lon},{lat} for each point and inspecting the snapped node. If the snapping distance exceeds 50 m, the point is not on a routable road. Verify OSM tag coverage using graph fragmentation prevention techniques for OSM data.

High latency on /table requests

Three causes: (1) --max-table-size is set higher than memory supports, causing paging; (2) the container is running CH instead of MLD — matrix queries perform better under MLD; (3) thread contention from simultaneous /route and /table requests. Lower --max-table-size, confirm --algorithm mld, and consider a dedicated /table container if batch matrix jobs run alongside real-time routing.

Container exits immediately after starting

Most commonly, the .osrm file path inside the container does not exist. Confirm with docker run --rm -v "$(pwd):/data" osrm/osrm-backend ls /data that the expected .osrm.* files are present. A second cause is a version mismatch: if the PBF was preprocessed with one OSRM version and the daemon image is a different major version, the file format is incompatible. Re-preprocess using the same image tag as the daemon.

MLD and CH produce different routes for the same query

This is expected for some queries. MLD uses partition-constrained shortest paths, which can differ slightly from CH’s globally optimal shortcuts when the cell boundary cuts through the optimal path. For pure shortest-path correctness on a static graph, use CH. For dynamic-weight deployments where you need to call osrm-customize frequently, accept MLD’s minor path differences in exchange for update flexibility.