The Whole Pie, Not Just a Slice
Geocoding is half the maps story. The other half is tiles — the actual little PNG/vector squares that render as a map in your app. Most “self-hosted maps” tutorials cover one or the other. Doing both, and getting them to share data, is the part that turns “I run a couple of containers” into “I have a working Google Maps replacement.”
Honestly, a lot of people don’t need both. If you only need address lookup, just run Nominatim. If you only need to render a map background, just run a tile server. But if your app does both — and a lot of apps do — running them together means one OSM data import feeds both stacks. That’s a real efficiency win, and it’s the only sensible approach for home lab use.
This post walks the combo: Nominatim for address lookup, PostGIS for spatial queries, Martin for vector tile serving. All sharing one Postgres install, all from one OSM import. We’ll wire them into a single Compose stack and end with a working geocode + map setup.
Full example: Compose file, Martin config, and sample frontend at github.com/KingPin/sumguy-examples/tree/main/self-hosting/nominatim-postgis-tile-server-stack
This post assumes you’ve read the prior pieces. If you haven’t:
- Nominatim: Self-Hosted Geocoding — the canonical install walkthrough
- PostGIS for Self-Hosted Mapping — spatial database fundamentals
We’re going to build on top of both.
Why One Stack and Not Three
You can absolutely run three independent services — Nominatim, a separate PostGIS for your spatial queries, and an entirely separate tile pipeline. Plenty of teams do. Here’s why I prefer the unified approach:
- One OSM import. Re-importing the planet (or a continent) takes hours to days. Doing it once and feeding three workloads is a real saving.
- One backup target. One Postgres install means one
pg_dumpstrategy. - Less moving parts at runtime. Three containers (Postgres, Nominatim, Martin) instead of six.
- Disk savings. Shared base data instead of three copies of similar OSM tables.
The cost is that you’re running Nominatim and Martin against the same Postgres instance, which means resource contention if either workload spikes. For home lab and small-shop traffic that’s not a real concern.
The Stack at a Glance
What we’re building:
┌──────────────┐ │ PostgreSQL │ │ + PostGIS │ │ │ │ - nominatim │ ← schema for geocoder │ - osm │ ← schema for tiles + spatial queries └───┬─────┬────┘ │ │ ┌────────────┘ └────────────┐ │ │┌──────▼──────┐ ┌──────▼──────┐│ Nominatim │ │ Martin ││ :8080 │ │ :3000 ││ /search │ │ /tiles/... ││ /reverse │ │ │└─────────────┘ └─────────────┘ │ │ └────────────┬───────────────────┘ │ ┌─────▼─────┐ │ Caddy │ │ geocode │ │ tiles │ └───────────┘Postgres holds the data. Nominatim handles geocoding queries. Martin serves vector tiles directly from PostGIS. Caddy puts a TLS hostname on each public endpoint.
Postgres + PostGIS as the Foundation
The mediagis Nominatim image bundles Postgres internally, which is convenient for a “just Nominatim” install but a problem when you want to share the database with other tools. The cleaner approach is to run Postgres as its own container and point Nominatim at it.
Here’s the base Postgres service:
services: postgres: image: postgis/postgis:16-3.4 container_name: postgres environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: maps volumes: - postgres-data:/var/lib/postgresql/data ports: - "5432:5432" shm_size: 1gb restart: unless-stopped command: - "postgres" - "-c" - "shared_buffers=2GB" - "-c" - "work_mem=256MB" - "-c" - "maintenance_work_mem=2GB" - "-c" - "max_wal_size=8GB"The postgis/postgis image is just standard Postgres with PostGIS preinstalled. The Postgres tuning above is reasonable for a 16 GB box. Bump shared_buffers to 25% of system RAM if you have more.
Now create the schemas Nominatim and tile-serving will use:
docker exec -it postgres psql -U postgres -d maps -c "CREATE SCHEMA nominatim;"docker exec -it postgres psql -U postgres -d maps -c "CREATE SCHEMA osm;"Importing OSM Data Once, Using It Twice
The ergonomics here are slightly tricky because Nominatim’s import is opinionated about its database. The cleanest path:
- Use the mediagis Nominatim setup to import OSM data into the
nominatimschema (or a dedicated database). - Separately import a tile-friendly representation (using
osm2pgsql --output=flexwith a vector-tiles config) into theosmschema.
Yes, this means two imports. They use the same source PBF, so the download is shared, and the parsing is incremental enough that running them sequentially is reasonable. For a continental extract (say North America), expect 12–18 hours for Nominatim and another 4–6 hours for the tile-serving import.
osm2pgsql for the tile schema:
docker run --rm \ -v $(pwd)/data:/data \ -e PGPASSWORD=$POSTGRES_PASSWORD \ --network=host \ iboates/osm2pgsql:latest \ osm2pgsql \ --create \ --output=flex \ --style=/data/style.lua \ --slim \ --cache=8000 \ --number-processes=8 \ -H localhost -d maps -U postgres \ /data/north-america-latest.osm.pbfThe style.lua controls which OSM features end up in which Postgres tables. It’s not something you download from the iboates/osm2pgsql image — you supply it yourself in ./data/style.lua. It’s an osm2pgsql flex-output Lua config: you define your tables and which OSM tags map into them.
The example repo ships a minimal one that produces exactly the osm.roads, osm.water, and osm.buildings tables Martin serves below (geometries in EPSG:3857). Use this one if you want the stack to run end to end without editing the Martin config.
Want something more complete? osm2pgsql ships its own flex-config examples, and generic.lua is the canonical “import everything” base. Heads up on what it actually does, though: it splits data by geometry type into points / lines / polygons / routes / boundaries (in the public schema, not osm) and dumps every tag into a single tags jsonb column rather than named highway/building columns. That’s a fine base — but it’s a different table shape, so you’d point Martin’s auto_publish at public (or adapt the style to emit osm.* tables) and query layers out of the jsonb. (And no, OpenMapTiles is not the answer here — it imports via imposm3 + YAML mappings, not an osm2pgsql Lua style, so there’s no drop-in style.lua to grab from that project.)
Wiring Nominatim to External Postgres
The vanilla mediagis/nominatim image runs Postgres internally. To point it at an external Postgres, you need to use the nominatim/nominatim upstream image instead, or build a small variant of mediagis that skips the bundled Postgres.
For most home labs, the simplest path is to keep mediagis as-is and accept that it has its own internal Postgres for the Nominatim data, while a separate postgis/postgis container holds your spatial query data and tile-serving tables. Two Postgres instances, two data volumes, but only one PBF import per side.
If you want truly shared Postgres, the upstream Nominatim docs cover it but the setup is more involved. Pick your battle.
Martin: Vector Tiles From PostGIS
Martin is a vector tile server that reads directly from PostGIS. Drop it in front of your osm schema and it auto-discovers tables and exposes them as MVT (Mapbox Vector Tile) endpoints. No tile pre-rendering, no on-disk tile cache to manage — it generates tiles on demand and lets the HTTP layer cache them.
services: martin: image: ghcr.io/maplibre/martin:latest container_name: martin environment: DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/maps ports: - "3000:3000" depends_on: - postgres restart: unless-stoppedThat’s it. Hit http://localhost:3000/catalog and Martin lists every PostGIS table it discovered, along with the tile URL pattern for each. Point a MapLibre or Leaflet client at those URLs and you have a working map.
For tighter control, drop a config.yaml and tell Martin exactly which tables and queries to expose:
postgres: connection_string: "postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/maps" default_srid: 3857 pool_size: 20 tables: osm.water: schema: osm table: water srid: 3857 geometry_column: geom minzoom: 0 maxzoom: 14 osm.roads: schema: osm table: roads srid: 3857 geometry_column: geom minzoom: 6 maxzoom: 18 osm.buildings: schema: osm table: buildings srid: 3857 geometry_column: geom minzoom: 13 maxzoom: 18Tune minzoom/maxzoom per layer. There’s no point serving building footprints at zoom 0; equally, water polygons at zoom 18 are wasted bandwidth.
Putting Caddy In Front
You probably want each service on its own hostname:
geocode.lan { reverse_proxy nominatim:8080}
tiles.lan { reverse_proxy martin:3000
header { Cache-Control "public, max-age=3600" Access-Control-Allow-Origin "*" }}The CORS header on tiles.lan matters if you’re loading tiles from a frontend on a different hostname. The cache header is helpful — Caddy will respect it and let browsers cache tiles.
Using It From a Frontend
A minimal MapLibre frontend that uses both your local geocoder and your local tile server:
<!DOCTYPE html><html><head> <link rel="stylesheet" href="https://unpkg.com/maplibre-gl/dist/maplibre-gl.css"> <style>html, body, #map { margin: 0; height: 100%; }</style></head><body> <div id="map"></div> <script src="https://unpkg.com/maplibre-gl/dist/maplibre-gl.js"></script> <script> const map = new maplibregl.Map({ container: 'map', style: 'https://tiles.lan/catalog', center: [-77.04, 38.9], zoom: 12 });
async function geocode(query) { const r = await fetch(`https://geocode.lan/search?q=${encodeURIComponent(query)}&format=json&limit=1`); const data = await r.json(); if (data[0]) { map.flyTo({ center: [parseFloat(data[0].lon), parseFloat(data[0].lat)], zoom: 16 }); } } </script></body></html>Two services, one frontend, no third-party dependencies. The style URL above is illustrative — in practice you’ll want a proper MapLibre style JSON that references your Martin sources with appropriate paint and layer rules. The openmaptiles style is a good starting point.
Spatial Queries: The Hidden Win
Once your osm schema is populated, you have a full PostGIS database to query directly. Address lookup is one thing; spatial reasoning is another.
-- All restaurants within 1 km of a coordinateSELECT name, ST_Distance(geom, ST_SetSRID(ST_MakePoint(-77.04, 38.9), 4326)::geography) AS dist_mFROM osm.amenitiesWHERE amenity = 'restaurant' AND ST_DWithin(geom, ST_SetSRID(ST_MakePoint(-77.04, 38.9), 4326)::geography, 1000)ORDER BY dist_mLIMIT 20;
-- Total length of all primary roads in a bounding boxSELECT SUM(ST_Length(geom::geography)) / 1000 AS total_kmFROM osm.roadsWHERE highway = 'primary' AND geom && ST_MakeEnvelope(-77.1, 38.85, -76.95, 38.95, 4326);Most apps that use Nominatim eventually want this kind of spatial query. Once you’ve imported the data once, you can lean on PostGIS for far more than just rendering tiles.
Resource Footprint of the Full Stack
Running the whole thing on one box, for a continental extract:
| Component | Disk | RAM (idle) | RAM (active) |
|---|---|---|---|
| Postgres + PostGIS | 200–400 GB | 4 GB | 8–12 GB |
| Nominatim (mediagis) | 100–200 GB | 2 GB | 4–8 GB during import |
| Martin | <1 GB | 200 MB | 500 MB–2 GB |
| Caddy | <1 GB | 50 MB | 200 MB |
| Total | ~500 GB | ~6 GB | ~14 GB |
So a 32 GB / 1 TB NVMe box is comfortable for the full stack on a continent. If you want the planet, double those numbers and budget seriously for the imports — see the hardware sizing post for the gory details.
Things That Will Bite You
- Two imports, two failure modes. Both have to succeed. If
osm2pgsqldies halfway, you’ll be re-running it; same for Nominatim. Don’t kick them off and walk away — at least monitor the first hour or two. - Disk space is the silent killer. A continental import + tile schema can balloon past 500 GB. Don’t run this on a 500 GB drive without breathing room.
- MVT versioning. MapLibre, Leaflet, and Mapbox GL all have slightly different vector tile expectations. Pick one client and stick with it.
- Tile cache invalidation. If you re-import, your CDN/browser-cached tiles will be stale. Either bump a version in the URL pattern or briefly drop your cache headers during a refresh.
- CORS will burn an hour. If your frontend can’t load tiles, check CORS headers in your reverse proxy first. Always.
- Postgres tuning matters more here. With both Nominatim and tile-serving hitting Postgres, you want decent
shared_buffers, decentwork_mem, and pgbouncer in front if you start seeing connection pressure.
When This Is Overkill
Plenty of self-hosters don’t actually need the full stack. If you only need to look up addresses, run Nominatim alone. If you only need to render a tile background, use a managed tile service like the ones from MapTiler or Stadia (they have generous free tiers and avoid the import pain entirely).
The full stack pays off when:
- Your app does both geocoding AND map rendering against your own data
- You can’t legally use commercial tile services for your use case (some industries have data sovereignty rules)
- You want spatial queries against OSM data that go beyond what any geocoder exposes
- You’re already running Postgres and the marginal cost of “one more set of tables” is small
For pure home lab use, my honest recommendation is: start with Nominatim alone. Add tile serving later if and when you actually need it. Don’t preemptively build the full stack to “have it” — disk and import time are real costs.
Wrapping Up
Five articles on Nominatim, and this is where they all converge. Geocoding from one container, spatial queries from another, vector tiles from a third, all sharing one OSM import. It’s not magic, it’s just a Compose file with three or four services and a sane reverse proxy in front.
The real point of self-hosting maps is the same as any other self-hosted thing: own the dependency, own the data, own the latency. Once the import is done, the runtime is boring — and boring is exactly what you want from infrastructure.
Now go give that Mini PC something to chew on for a weekend.
Related posts
- Nominatim: Self-Hosted Geocoding — install walkthrough
- Nominatim vs Photon vs Pelias — picking the right engine
- Reverse Geocoding for Home Assistant — privacy-friendly device tracking
- Nominatim Hardware Sizing — RAM and disk math
- PostGIS for Self-Hosted Mapping — the spatial database deep dive