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: '© 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.

