Self-Hosted Map Server with OpenStreetMap Tile Server and Nominatim: Replace Google Maps API in Your HomeLab

HomeLab tutorial - IT technology blog
HomeLab tutorial - IT technology blog

The Real Cost of Relying on Google Maps

Every geocoding call, every tile load, every map embed in your app goes through Google’s servers. At small scale, the free tier holds. But costs scale fast: dynamic map loads run $7 per 1,000 requests, geocoding another $5 per 1,000. Once your project gets any traction — or once you start caring where your users’ location data ends up — Google Maps stops being a convenience and starts being a liability.

The deeper issue isn’t just billing. Google treats every query as a data point. If you’re building a fleet tracker, a home automation dashboard with location features, or any internal tool where location data is sensitive, routing that data externally is a risk worth eliminating.

Three Approaches to Map Infrastructure

When you need map functionality, there are three realistic paths:

Managed APIs (Google Maps, Mapbox, HERE)

Easy to start, excellent data quality, but you pay per call, operate under rate limits, and every request leaves your network. For a HomeLab project or a privacy-sensitive application, hard to justify.

Direct OpenStreetMap Public Tiles

OpenStreetMap provides free tiles at tile.openstreetmap.org, but their usage policy prohibits heavy use in production apps. Fine for development, not for anything real.

Self-Hosted OSM Stack

Run your own tile rendering server and geocoder using OSM data. No per-call costs, no data leaving your network, no rate limits. The trade-off is setup effort and hardware — but for HomeLab use, that’s a trade worth making.

Two core components make up this stack:

  • Tile server — renders map images from OSM data, using openstreetmap-tile-server (built on mod_tile + renderd + Mapnik)
  • Nominatim — handles geocoding (address → coordinates) and reverse geocoding (coordinates → address)

Pros and Cons: What You’re Actually Trading

What you gain

  • Zero API costs after hardware investment
  • Location data never leaves your network
  • No rate limits — serve as many requests as your hardware allows
  • Full offline capability
  • Map style and data under your control

What you’re taking on

  • Initial data import takes time — 30 minutes to several hours depending on region size
  • Disk space is significant: a country extract needs 10–50GB, the full planet needs ~1TB
  • You own updates and maintenance
  • Rendering performance depends on your hardware

Disk and time numbers look worse than they are. Start with a regional extract rather than the whole planet. Japan, for example, downloads as a ~900MB PBF file and imports cleanly on modest hardware. You get 95% of what you need with a fraction of the storage.

Recommended Setup

I’ve run this stack in production for a vehicle tracking application covering a metro area. On a machine with 8GB RAM and an SSD, the tile server handled hundreds of tile requests per minute without a hiccup — and it’s been running that way for months without surprises.

Hardware minimums that actually work:

  • RAM: 8GB minimum, 16GB preferred for comfortable Nominatim operation
  • Storage: SSD required — spinning disks make the import 5–10× slower
  • CPU: 4 cores minimum for tile rendering

Software stack: Docker + Docker Compose, overv/openstreetmap-tile-server for tile rendering, and mediagis/nominatim for geocoding.

Implementation Guide

Step 1: Download a regional OSM extract

Geofabrik provides free regional extracts updated daily. Browse https://download.geofabrik.de and pick your region.

mkdir -p ~/homelab/maps/data
cd ~/homelab/maps/data

# Japan example — swap the URL for your region
wget https://download.geofabrik.de/asia/japan-latest.osm.pbf

Step 2: Write the docker-compose.yml

version: "3"
services:
  tile-server:
    image: overv/openstreetmap-tile-server:2.4.0
    volumes:
      - osm-data:/data/database
      - ./data/japan-latest.osm.pbf:/data/region.osm.pbf
    ports:
      - "8080:80"
    environment:
      - THREADS=4
    command: import
    restart: "no"

  nominatim:
    image: mediagis/nominatim:4.4
    volumes:
      - nominatim-data:/var/lib/postgresql/14/main
      - ./data/japan-latest.osm.pbf:/nominatim/data.osm.pbf
    ports:
      - "8081:8080"
    environment:
      - PBF_PATH=/nominatim/data.osm.pbf
      - REPLICATION_URL=https://download.geofabrik.de/asia/japan-updates/
      - NOMINATIM_PASSWORD=changeme_use_something_strong
    shm_size: 1gb
    restart: unless-stopped

volumes:
  osm-data:
  nominatim-data:

Step 3: Import tile data (run once)

This converts the raw OSM data into a PostGIS database the renderer reads. Run it once and wait:

docker compose up tile-server
# Watch for "INFO: import done" in the logs, then stop
docker compose stop tile-server

Once import finishes, change command in docker-compose.yml from import to run, then start it as a persistent service:

docker compose up -d tile-server

Step 4: Import Nominatim data

On first start, Nominatim detects the PBF file and kicks off a full import automatically. Japan takes roughly 1–2 hours. Get a coffee:

docker compose up nominatim
# Watch the logs; it imports then switches to service mode on its own

Step 5: Verify both services

# Test tile server — should save a valid PNG
curl -o /tmp/test.png "http://localhost:8080/tile/10/909/403.png"
file /tmp/test.png  # Expect: PNG image data

# Test geocoding
curl "http://localhost:8081/search?q=Tokyo+Station&format=json&limit=1"

# Test reverse geocoding
curl "http://localhost:8081/reverse?lat=35.6812&lon=139.7671&format=json"

Step 6: Connect Leaflet.js to your tile server

Leaflet is the standard open-source map library. One URL change is all it takes to point it at your server:

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
  <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
  <style>#map { height: 600px; }</style>
</head>
<body>
  <div id="map"></div>
  <script>
    const map = L.map('map').setView([35.6812, 139.7671], 13);
    L.tileLayer('http://YOUR_SERVER_IP:8080/tile/{z}/{x}/{y}.png', {
      maxZoom: 19,
      attribution: '&copy; OpenStreetMap contributors'
    }).addTo(map);
  </script>
</body>
</html>

Step 7: Geocoding from Python

import requests

NOMINATIM_BASE = "http://YOUR_SERVER_IP:8081"

def geocode(address: str) -> dict:
    resp = requests.get(
        f"{NOMINATIM_BASE}/search",
        params={"q": address, "format": "json", "limit": 1},
        headers={"User-Agent": "MyApp/1.0"},
    )
    results = resp.json()
    if results:
        return {"lat": float(results[0]["lat"]), "lon": float(results[0]["lon"])}
    return {}

def reverse_geocode(lat: float, lon: float) -> str:
    resp = requests.get(
        f"{NOMINATIM_BASE}/reverse",
        params={"lat": lat, "lon": lon, "format": "json"},
        headers={"User-Agent": "MyApp/1.0"},
    )
    return resp.json().get("display_name", "")

Keeping Data Current

Nominatim’s REPLICATION_URL handles automatic OSM diff updates from Geofabrik — it pulls and applies change files without a full re-import. Check replication status anytime:

curl "http://localhost:8081/status"

Tile data works differently. Rendered tiles are cached on disk, and new OSM data doesn’t automatically invalidate them — you need a re-import to pick up map changes. For most HomeLab scenarios, quarterly is fine. The import runs in the background while the server keeps serving cached tiles.

Optional: Nginx Reverse Proxy

Want clean URLs or HTTPS via Certbot? Drop this in:

server {
    listen 80;
    server_name maps.homelab.local;

    location /tiles/ {
        proxy_pass http://localhost:8080/tile/;
        proxy_cache_valid 200 7d;
        add_header Cache-Control "public, max-age=604800";
    }

    location /nominatim/ {
        proxy_pass http://localhost:8081/;
    }
}

When This Stack Makes Sense

Fleet tracking, smart home dashboards with map views, internal delivery apps — anything where sending location data to a third party isn’t an option. That’s where this setup earns its place.

A few hours of import work up front, then the stack just runs. Tiles render, geocoding responds, nothing leaves your network. No invoices, no rate limit errors, no questions about where the data goes.

Share: