Self-Hosting GraphHopper on Kubernetes with OSM Data
Answer-first: Self-hosting GraphHopper routing engines on Kubernetes uses initContainers for S3 graph cache hydration, JVM heap tuning, and HPA auto-scaling to process heavy routing traffic. Sizing pods with 4GB off-heap memory and 1GB JVM heap for country-level OpenStreetMap data achieves sub-50ms routing queries while cutting commercial map API costs by over 95%.
GraphHopper is arguably the most capable open-source routing engine available — it supports Contraction Hierarchies (CH) for sub-millisecond route queries, custom vehicle profiles, turn restrictions, and the full OpenStreetMap road network. The problem most teams encounter is not the algorithm; it is the operational challenge of running it in Kubernetes: loading a large OSM PBF file, sizing JVM memory correctly, handling the long CH pre-processing startup time, and updating map data without downtime.
This post is a Kubernetes deployment guide for GraphHopper using real OpenStreetMap data. By the end, you will have a StatefulSet deployment with persistent OSM graph files, correctly sized JVM resources, liveness and readiness probes that account for CH pre-processing time, and a zero-downtime map update strategy.
For the routing algorithm comparison and API usage, see GraphHopper vs CARTO: Order Fulfillment Routing Engine.
Why Self-Host GraphHopper? Cost vs. API Trade-Off Analysis
Before committing to a self-hosted deployment, weigh the options:
| Factor | Self-Hosted GraphHopper | GraphHopper Cloud API | Google Maps Routes API |
|---|---|---|---|
| Per-query cost | Infrastructure amortized | ~$0.005–0.008/request | $0.005–0.010/request |
| At 1M queries/day | ~$200–400/month (K8s) | ~$5,000–8,000/month | ~$5,000–10,000/month |
| Data freshness | Manual OSM update | Weekly OSM updates | Google’s proprietary |
| Offline capability | Yes (no internet required) | No | No |
| Custom profiles | Full control | Limited | No |
| Operational burden | High | Low | Low |
Self-hosting is the right choice when:
- Query volume exceeds ~200,000 requests/day (API costs exceed infrastructure costs)
- You need offline operation (logistics in areas with poor connectivity)
- Your routing requirements need custom vehicle profiles (e-bikes, cargo trucks, motorcycle)
- Data sovereignty requirements prohibit sending GPS coordinates to third-party APIs
For most logistics platforms in Southeast Asia, the break-even volume is around 100,000–200,000 routing queries per day. Below that threshold, the GraphHopper Cloud API is cost-effective. If you are evaluating GraphHopper for order fulfillment and last-mile routing, see Order Fulfillment Algorithm: Warehouse to Last-Mile for how the routing engine fits into the broader allocation pipeline. For platforms needing real-time geospatial compute with dynamic pricing signals, see Surge Pricing Algorithm & Spatial Indexing Architecture.
Choosing the Right OSM PBF Data: Country, Region, or Bounding Box
OpenStreetMap provides free map data downloads in PBF (Protocol Buffer Format) through Geofabrik (the primary mirror). Balance geographic coverage against RAM constraints — use focused country-level archives over continental datasets to optimize memory consumption and minimize startup delays.
Data Size Reference
| Geography | Compressed PBF | CH Graph RAM (car profile) |
|---|---|---|
| Vietnam | ~170 MB | ~2–3 GB |
| Southeast Asia | ~1.2 GB | ~15–20 GB |
| Japan | ~1.0 GB | ~12–18 GB |
| Germany | ~3.5 GB | ~40–60 GB |
| Europe (full) | ~28 GB | ~300+ GB |
For logistics applications serving a single country, use the country-level extract. For multi-country coverage, use the regional extract. Avoid using the global planet file (>90 GB compressed) unless you have 512+ GB RAM available for graph pre-processing.
Downloading OSM Data
# Vietnam only
wget https://download.geofabrik.de/asia/vietnam-latest.osm.pbf
# Southeast Asia (includes Vietnam, Thailand, Indonesia, Philippines, etc.)
wget https://download.geofabrik.de/asia/south-east-asia-latest.osm.pbf
# Verify the download integrity
wget https://download.geofabrik.de/asia/vietnam-latest.osm.pbf.md5
md5sum -c vietnam-latest.osm.pbf.md5
Store the PBF file in a Kubernetes PersistentVolume (PVC) — not in the container image. The OSM file is too large for a container layer, and it needs to persist across pod restarts.
The GraphHopper Docker Image: Build vs. Official Image
GraphHopper publishes an official Docker image on GitHub Container Registry. For production, pin a specific version:
docker pull ghcr.io/graphhopper/graphhopper:latest
For production use, pin a specific version:
docker pull ghcr.io/graphhopper/graphhopper:10.0
Custom Configuration via ConfigMap
Create a graphhopper.yml configuration file. The minimal production configuration:
# graphhopper.yml
graphhopper:
# Path to the OSM PBF file inside the container (mounted from PVC)
datareader.file: /data/osm/vietnam-latest.osm.pbf
# Graph cache directory (where CH graphs are stored between restarts)
graph.location: /data/graph-cache
# Profiles to prepare CH graphs for
profiles:
- name: car
vehicle: car
weighting: fastest
- name: motorcycle
vehicle: motorcycle
weighting: fastest
- name: bike
vehicle: bike
weighting: fastest
ch.profiles: car,motorcycle,bike
# Server configuration
server:
application_connectors:
- type: http
port: 8989
admin_connectors:
- type: http
port: 8990
# Import only (no live data sources)
import.osm.ignored.highways: ""
Store this configuration as a Kubernetes ConfigMap:
kubectl create configmap graphhopper-config \
--from-file=graphhopper.yml=./graphhopper.yml \
-n logistics
Kubernetes Deployment Architecture: PersistentVolume for OSM Graph Files
GraphHopper requires two persistent directories mounted to the pod:
- OSM PBF input (
/data/osm/): The raw map data file - Graph cache (
/data/graph-cache/): The pre-processed CH graph (survives restarts — avoids re-processing on every pod start)
graph TD
PVC["PersistentVolumeClaim: graphhopper-data - 30Gi ReadWriteOnce"]
PVC --> OSM["/data/osm/vietnam-latest.osm.pbf"]
PVC --> GRAPH["/data/graph-cache/ - CH graphs"]
POD["GraphHopper Pod"]
POD --> PVC
POD --> CM["ConfigMap: graphhopper-config"]
SVC["Service: graphhopper-svc :8989"] --> POD
PersistentVolumeClaim
# graphhopper-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: graphhopper-data
namespace: logistics
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 30Gi # For Vietnam: OSM PBF (~170MB) + CH graphs (~3GB) + buffer
storageClassName: ssd # Use SSD storage class for faster graph reads
Uploading the OSM PBF File to the PVC
Use a temporary pod with the PVC mounted to copy the OSM file:
# Create a temporary upload pod
kubectl run osm-uploader --image=alpine --restart=Never \
--overrides='{"spec":{"volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"graphhopper-data"}}],"containers":[{"name":"uploader","image":"alpine","command":["sleep","3600"],"volumeMounts":[{"name":"data","mountPath":"/data"}]}]}}' \
-n logistics
# Wait for the pod to start
kubectl wait --for=condition=ready pod/osm-uploader -n logistics
# Copy the OSM file into the PVC
kubectl cp vietnam-latest.osm.pbf logistics/osm-uploader:/data/osm/vietnam-latest.osm.pbf
# Clean up
kubectl delete pod osm-uploader -n logistics
RAM and JVM Tuning: How to Size Kubernetes Requests/Limits for CH Graphs
This is the most common source of GraphHopper failures in Kubernetes: the JVM OOMKill.
Memory Requirements
GraphHopper loads the entire CH graph into memory (memory-mapped files via Java’s MappedByteBuffer). This memory is off-heap — it does not count against the JVM heap (-Xmx) but does count against the container’s total memory limit.
For Vietnam with car + motorcycle + bike profiles:
- JVM heap (
-Xmx): 512MB–1GB (for GC overhead and route computation) - CH graph mmap (off-heap): ~2–3 GB
- Container memory limit:
-Xmx+ graph size + 512MB buffer = 4–5 GB
JVM Configuration
Set JVM options via the JAVA_OPTS environment variable:
env:
- name: JAVA_OPTS
value: >-
-Xmx768m
-Xms256m
-XX:+UseG1GC
-XX:MaxDirectMemorySize=4g
-XX:+ExitOnOutOfMemoryError
-Djava.nio.file.spi.DefaultFileSystemProvider=sun.nio.fs.UnixFileSystemProvider
Key flags:
-Xmx768m: JVM heap cap (route computation, GC metadata)-XX:MaxDirectMemorySize=4g: Off-heap direct memory limit (covers mmap’d graph files)-XX:+ExitOnOutOfMemoryError: Forces a clean JVM exit on OOM instead of a degraded state — lets Kubernetes restart the pod cleanly
Kubernetes Resource Requests and Limits
resources:
requests:
memory: "4Gi" # Scheduling: ensures node has enough free memory
cpu: "1000m" # 1 vCPU for CH queries (adjust based on query rate)
limits:
memory: "5Gi" # 4GB graph mmap + 768MB heap + 512MB buffer
cpu: "4000m" # Allow bursting during CH pre-processing startup
StatefulSet vs. Deployment: Which K8s Workload Type to Use
GraphHopper requires a PersistentVolume with ReadWriteOnce access mode (a single writer). This creates a fundamental Kubernetes constraint: you cannot run multiple replicas of GraphHopper on the same PVC simultaneously.
Use StatefulSet when:
- Each replica needs its own independent PVC (multiple GraphHopper instances with different regional data)
- You need stable pod names for discovery
Use Deployment when:
- A single instance is sufficient (most logistics platforms)
- You use a shared
ReadWriteManynetwork storage (NFS or Ceph) — more expensive but allows multi-replica reads
For most production deployments, a single GraphHopper instance is sufficient up to ~2,000 route queries per second (limited by CPU, not I/O after the graph is memory-mapped). The complete StatefulSet manifest:
# graphhopper-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: graphhopper
namespace: logistics
spec:
serviceName: graphhopper
replicas: 1
selector:
matchLabels:
app: graphhopper
template:
metadata:
labels:
app: graphhopper
spec:
containers:
- name: graphhopper
image: ghcr.io/graphhopper/graphhopper:10.0
args: ["start", "/config/graphhopper.yml"]
ports:
- name: http
containerPort: 8989
- name: admin
containerPort: 8990
env:
- name: JAVA_OPTS
value: "-Xmx768m -Xms256m -XX:+UseG1GC -XX:MaxDirectMemorySize=4g -XX:+ExitOnOutOfMemoryError"
resources:
requests:
memory: "4Gi"
cpu: "1000m"
limits:
memory: "5Gi"
cpu: "4000m"
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /config
# Readiness: GraphHopper is ready ONLY after CH pre-processing finishes
readinessProbe:
httpGet:
path: /health
port: 8990
initialDelaySeconds: 60 # Allow time for graph loading to start
periodSeconds: 15
failureThreshold: 40 # 40 * 15s = 10 min max wait for CH pre-processing
# Liveness: restart if the JVM is frozen (not just slow to start)
livenessProbe:
httpGet:
path: /health
port: 8990
initialDelaySeconds: 300 # 5 min — CH pre-processing must complete first
periodSeconds: 30
failureThreshold: 3
volumes:
- name: config
configMap:
name: graphhopper-config
# The PVC template creates one PVC per replica
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 30Gi
storageClassName: ssd
Zero-Downtime OSM Map Updates: Rolling Graph Regeneration Strategy
OSM data becomes stale over time — new roads, road closures, and access restriction changes accumulate. A practical update cadence for logistics is weekly (Geofabrik publishes weekly country extracts).
The Blue-Green Graph Update Pattern
Since ReadWriteOnce prevents running two GraphHopper instances on the same PVC, use a blue-green approach:
graph LR
SVC["Service: graphhopper-svc"] -->|"routes to"| ACTIVE["StatefulSet: graphhopper-blue - ACTIVE"]
STANDBY["StatefulSet: graphhopper-green - UPDATING"]
subgraph Update Flow
DOWNLOAD["Download new OSM PBF"] --> COPY["kubectl cp to green PVC"]
COPY --> RESTART["kubectl rollout restart graphhopper-green"]
RESTART --> WAIT["Wait for readiness probe"]
WAIT --> SWITCH["Patch Service selector to green"]
SWITCH --> CLEANUP["Delete old blue StatefulSet"]
end
This approach requires maintaining two StatefulSets with separate PVCs, but ensures the active instance is never interrupted during graph regeneration. Automate the update with a Kubernetes CronJob that:
- Downloads the new OSM PBF
- Copies it to the standby PVC
- Restarts the standby pod and waits for readiness
- Patches the Service selector to point to the standby
- Marks the previous active as the new standby
For the GitOps workflow that manages CronJob and StatefulSet configuration in source control, see GitOps at Scale: Kubernetes & ArgoCD for Microservices.
Health Probes and Readiness Gates: Waiting for CH Pre-Processing to Complete
The single most common misconfiguration is setting initialDelaySeconds too short on the readiness probe. GraphHopper startup involves four distinct phases:
- Load the OSM PBF file (first run: 2–5 minutes for Vietnam)
- Build the Contraction Hierarchy graph (first run: 5–15 minutes)
- Save the CH graph to disk
- Load the CH graph into memory (subsequent runs: 1–3 minutes)
On subsequent restarts (when the CH graph cache already exists), startup is 1–3 minutes. But on the first run after a new OSM file, it can take 15–20 minutes.
The readiness probe configuration above uses initialDelaySeconds: 60 with failureThreshold: 40 (600 seconds of patience) — sufficient for most country-level graphs. The liveness probe uses initialDelaySeconds: 300 to prevent Kubernetes from restarting a slow-starting pod that is simply doing CH pre-processing.
Monitoring GraphHopper on Kubernetes with Prometheus & Grafana
GraphHopper’s admin port exposes metrics in Dropwizard format. Use a Prometheus JMX exporter or the metrics endpoint to scrape:
# ServiceMonitor for Prometheus Operator
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: graphhopper
namespace: logistics
spec:
selector:
matchLabels:
app: graphhopper
endpoints:
- port: admin
path: /metrics
interval: 30s
Key metrics to alert on:
graphhopper_routing_requests_total— request rate (alert on >0 error rate)graphhopper_routing_time_seconds— P99 route computation latency (alert if >200ms)jvm_memory_used_bytes{area="heap"}— JVM heap utilization (alert if >85% of-Xmx)process_resident_memory_bytes— total process RSS (alert if approaching container memory limit)
Integrate these dashboards with the broader Kubernetes observability stack described in Go pprof in Kubernetes: Remote Profiling & Flame Graphs.
Frequently Asked Questions
How much RAM does GraphHopper need to serve Vietnam's road network?
Can I run GraphHopper on Kubernetes with multiple replicas?
ReadWriteOnce PVCs (standard SSD storage) only support a single writer — you cannot mount the same PVC to two pods simultaneously. For multi-replica serving, use ReadWriteMany storage (NFS or Ceph RBD) and a Deployment instead of StatefulSet. Alternatively, run separate StatefulSets per region, each with their own PVC, and route requests by geography.How do I update the OSM map data without downtime?
Related Guides & Topic Cluster
- Distance Matrix Production Guide: Deploy and benchmark
/matrixqueries with Redis H3 caching in GraphHopper Distance Matrix: Self-Hosted Routing & API Guide. - Fleet Optimization Solver: Feed distance matrices into a Go ALNS solver in CVRP & VRPTW Fleet Optimization: Go ALNS Routing Engine.
- Engine Showdown: Compare OSRM and GraphHopper architectures in OSRM vs GraphHopper Architecture Comparison.
- Pillar Hub: Explore the full 8-part masterclass in Geospatial & Routing Engine Architecture.
- Performance Profiling: Trace Go and JVM bottlenecks in Go pprof in Kubernetes: Remote Profiling & Flame Graphs.
