Skip to content
Go back

Full Self-Hosted Maps Stack

By SumGuy 12 min read
Full Self-Hosted Maps Stack

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:

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:

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:

docker-compose.yml
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:

Terminal window
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:

  1. Use the mediagis Nominatim setup to import OSM data into the nominatim schema (or a dedicated database).
  2. Separately import a tile-friendly representation (using osm2pgsql --output=flex with a vector-tiles config) into the osm schema.

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:

Terminal window
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.pbf

The 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.

docker-compose.yml
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-stopped

That’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:

martin-config.yaml
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: 18

Tune 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:

Caddyfile
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:

index.html
<!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.

example queries
-- All restaurants within 1 km of a coordinate
SELECT name, ST_Distance(geom, ST_SetSRID(ST_MakePoint(-77.04, 38.9), 4326)::geography) AS dist_m
FROM osm.amenities
WHERE amenity = 'restaurant'
AND ST_DWithin(geom, ST_SetSRID(ST_MakePoint(-77.04, 38.9), 4326)::geography, 1000)
ORDER BY dist_m
LIMIT 20;
-- Total length of all primary roads in a bounding box
SELECT SUM(ST_Length(geom::geography)) / 1000 AS total_km
FROM osm.roads
WHERE 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:

ComponentDiskRAM (idle)RAM (active)
Postgres + PostGIS200–400 GB4 GB8–12 GB
Nominatim (mediagis)100–200 GB2 GB4–8 GB during import
Martin<1 GB200 MB500 MB–2 GB
Caddy<1 GB50 MB200 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

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:

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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Boundary vs Teleport

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts