Skip to content
XGitHubEmail

Engineering

Self-Hosting Pelias: Geocoding for African Addresses

How we deployed Pelias geocoder on a single VM to handle descriptive African addresses that Google Maps can't resolve — for $5/month.

KK
Kudapara Kady· Founder & Engineer
Apr 15, 2026·8 min read
geocodingpeliasinfraafrica

Google Maps can’t find “behind the Shell garage, Dangamvura, Mutare.” But the rider who needs to deliver there knows exactly where it is. The problem isn’t the address — it’s the geocoder.

Most geocoders assume structured addresses: street number, street name, city, postal code. African addresses are descriptive: landmarks, neighborhoods, relational directions. You don’t live at “123 Main Street” — you live “near the butcher shop in Chitungwiza.”

We solved this by self-hosting Pelias. Here’s how.

Why Pelias

Pelias is an open-source geocoder built by Mapzen. It’s designed to be self-hosted and uses OpenStreetMap (OSM) data as its primary source. Two things made it the right choice:

  1. OSM data in Africa is often better than Google’s. Local mappers know the landmarks, the neighborhoods, the informal paths. Google’s data is collected from satellites and official sources — it misses the ground truth.
  2. Fuzzy matching by default. Pelias doesn’t require exact matches. It does fuzzy text matching against a trie-based index, which handles “Dangamvura” vs “Dangambura” vs “Dangmvura” naturally.

The Deployment: One VM, $5/month

We run Pelias on a single Hetzner VM (CX21, 4GB RAM, 80GB disk). It handles our entire geocoding load — ~10,000 requests/day — with room to spare.

The data pipeline:

#!/bin/bash
# Download OSM data for Zimbabwe
wget -O data/zimbabwe.osm.pbf \
  "https://download.geofabrik.de/africa/zimbabwe-latest.osm.pbf"

# Import into Pelias
docker compose run --rm whosonfirst import
docker compose run --rm openstreetmap import
docker compose run --rm openaddresses import
docker compose run --rm polity一千 import

# Build the index
docker compose run --rm elasticsearch create-indices
docker compose run --rm interpolation build

The full import takes ~2 hours and uses ~40GB of disk. The resulting index fits in 2GB of RAM with Elasticsearch’s mmap-based caching.

The API: Simple, Fast, Good

# app/services/pelias_geocoding_service.rb
class PeliasGeocodingService
  BASE_URL = 'http://pelias:4000/v1'

  def search(query, options = {})
    params = {
      text: query,
      size: options[:size] || 1,
      'boundary.country' => options[:country] || 'ZWE'
    }.compact

    response = HTTP.get("#{BASE_URL}/search", params: params)
    parse_results(response)
  end

  def reverse(lat:, lng:, options = {})
    params = {
      'point.lat' => lat,
      'point.lon' => lng,
      size: options[:size] || 1
    }

    response = HTTP.get("#{BASE_URL}/reverse", params: params)
    parse_results(response)
  end

  private

  def parse_results(response)
    data = JSON.parse(response.body.to_s)
    return [] unless data['features']

    data['features'].map do |feature|
      {
        label: feature['properties']['label'],
        coordinates: feature['geometry']['coordinates'],
        confidence: feature['properties']['confidence'],
        source: feature['properties']['source']
      }
    end
  end
end

The In-Memory Cache

For Muchiround, we see the same addresses repeatedly — a provider’s regular stops don’t change often. We added a simple in-memory LRU cache:

# app/services/geocode_cache.rb
class GeocodeCache
  CACHE_KEY = 'geocode_cache'
  MAX_SIZE = 10_000

  def initialize
    @cache = ActiveSupport::Cache::MemoryStore.new(
      size: MAX_SIZE,
      expires_in: 24.hours
    )
  end

  def fetch(address)
    key = "#{CACHE_KEY}:#{Digest::MD5.hexdigest(address.downcase)}"

    @cache.fetch(key) do
      yield
    end
  end

  def clear
    @cache.clear
  end
end

This cuts our Pelias requests by ~60%. The cache hits resolve in microseconds; misses hit Pelias in ~50ms.

Handling the Worst Case

Some addresses just can’t be geocoded. “Turn left at the blue house after the tuckshop” has no coordinates. For these, we fall back to manual placement:

  1. The rider app has a “drop pin” mode. The rider drops a pin at the actual location.
  2. The pin is saved with the original descriptive address as a reference.
  3. Next time that address is entered, the pin is used.

Over time, this builds a custom geocoding layer on top of Pelias — one that gets more accurate with every delivery.

The Cost Comparison

Solution Monthly Cost Coverage Latency
Google Maps API $200+ (10k requests) 40% hit rate in Zimbabwe 200ms
Pelias (self-hosted) $5 (VM) 75% hit rate in Zimbabwe 50ms
Pelias + manual pins $5 (VM) 95% hit rate 20ms (cached)

The 75% hit rate is the out-of-the-box number. With manual pins accumulated over 3 months, we’re at 95%. Google Maps was at 40% because it couldn’t handle descriptive addresses at all.

The Bigger Point

Africa doesn’t need Western infrastructure. It needs infrastructure that understands the terrain. Self-hosted Pelias is our geocoding layer — not because it’s cheaper (it is), but because it’s better for our context.

The assumption that proprietary services are always better is wrong. When your problem doesn’t match their assumptions, open source wins. African addresses don’t match Google’s assumptions. So we built our own.

For $5 a month.

KK

Kudapara Kady

Founder & Engineer

Building software for Africa at Kudapara. Engineering, AI, and logistics from the ground.