You’re Paying Google to Store Lat/Long Coordinates
Here’s the thing: mapping isn’t magic. It’s just numbers. A latitude, a longitude, a name. Yet somehow the moment you want to embed a map on your self-hosted site, you’re writing a check to Google, paying per request, accepting their tracking pixels, and hoping they don’t change their pricing on a Tuesday.
What if you didn’t have to?
PostGIS is Postgres with spatial superpowers — geometry types, spatial indexes, tile generation. Add OpenStreetMap data, a tile server like Martin or pg_tileserv, and you’ve got maps on your own hardware. No APIs. No quota limits. No landlord.
Let’s build one.
What You’re Actually Building
Before you get lost in SQL syntax, here’s the mental model:
- PostgreSQL + PostGIS extension — the database that understands “points on Earth”
- OSM data (via osm2pgsql) — planet data sliced down to your region of interest, loaded into tables
- Spatial queries — “find all restaurants within 2km of this point” runs as fast as your indexes
- Tile server (Martin or pg_tileserv) — HTTP endpoint that returns map tiles on demand
- Frontend (Leaflet, Mapbox GL, Deck.gl) — your website consumes those tiles and draws them
It’s like the difference between paying a trucking company to haul your stuff versus renting a warehouse and moving it yourself. You handle the infrastructure, you control the cost.
Installing PostGIS
On a modern Postgres install (15+, ideally 16), PostGIS is usually one CREATE EXTENSION away.
# On Ubuntu/Debian, install the postgis packagesudo apt-get install postgresql postgresql-contrib postgis postgresql-<version>-postgis
# Start or restart Postgressudo systemctl restart postgresql
# Connect as the postgres usersudo -u postgres psqlInside psql:
-- Create your maps databaseCREATE DATABASE osm_maps;
-- Connect to it\c osm_maps
-- Enable PostGISCREATE EXTENSION postgis;
-- Check it workedSELECT PostGIS_version();You’re done. Postgres now speaks geography.
Importing OpenStreetMap Data with osm2pgsql
OSM publishes the entire world as a .pbf file. You don’t need the entire world. Download your region from Geofabrik (https://download.geofabrik.de/), or extract a smaller bounding box yourself with osmium-tool.
# Install osm2pgsqlsudo apt-get install osm2pgsql
# Download a region extract (e.g., North America)wget https://download.geofabrik.de/north-america-latest.osm.pbf
# Import into Postgresosm2pgsql \ --database osm_maps \ --user postgres \ --host localhost \ --create \ --slim \ --log-progress true \ --number-processes 4 \ --style /usr/share/osm2pgsql/default.style \ north-america-latest.osm.pbfWait 30 minutes to a few hours depending on region size. osm2pgsql will:
- Create
planet_osm_point,planet_osm_line,planet_osm_polygon,planet_osm_roadstables - Build spatial indexes
- Tag features (shop, amenity, highway, etc.)
- Handle the geometry projection (Web Mercator)
Once done, you have millions of geographic objects in Postgres. You own them. They don’t leave your server.
Basic Spatial Queries
Now the fun part. Queries that would cost you $$$$ on Google Maps now cost you CPU cycles.
-- Find all coffee shops within 5km of a point (lat, lon)-- Using ST_DWithin: distance within X metersSELECT name, amenity, ST_AsText(way) AS locationFROM planet_osm_pointWHERE amenity = 'cafe' AND ST_DWithin( way::geography, ST_MakePoint(-122.4194, 37.7749)::geography, 5000 -- 5km in meters )LIMIT 20;
-- Count all buildings in a bounding boxSELECT COUNT(*) AS building_countFROM planet_osm_polygonWHERE building IS NOT NULL AND ST_Intersects( way, ST_MakeEnvelope(-122.5, 37.7, -122.4, 37.8, 4326) );
-- Find the nearest hospital to a pointSELECT name, amenity, ST_Distance(way::geography, ST_MakePoint(-122.4194, 37.7749)::geography) / 1000 AS distance_kmFROM planet_osm_pointWHERE amenity = 'hospital'ORDER BY distance_km ASCLIMIT 1;
-- Get all street names in a polygon (e.g., your neighborhood)SELECT DISTINCT nameFROM planet_osm_lineWHERE highway IS NOT NULL AND ST_Intersects(way, ST_MakeEnvelope(-122.5, 37.7, -122.4, 37.8, 4326))ORDER BY name;Index these queries on frequently-searched columns, and you’re in the ballpark of millisecond responses, even with millions of features.
Generating Tiles: Martin or pg_tileserv
Here’s where you trade “click a map on your site” for “render tiles on demand.”
Option 1: Martin (Preferred)
Martin is a Rust tile server. It’s fast, it’s reliable, and it auto-detects your PostGIS tables.
# Install Martin (from releases or cargo)wget https://github.com/maplibre/martin/releases/download/v0.14.0/martin-x86_64-unknown-linux-gnuchmod +x martin-x86_64-unknown-linux-gnusudo mv martin-x86_64-unknown-linux-gnu /usr/local/bin/martin
# Create configcat > martin.toml << 'EOF'# martin.tomlbind_address = "0.0.0.0:3000"
[postgresql]connection_string = "postgresql://postgres:password@localhost:5432/osm_maps"
# Auto-detect all tables with geometryauto_detect = true
# Custom layer definitions (optional)[[tiles]]id = "buildings"sql = """SELECT way, building, nameFROM planet_osm_polygonWHERE building IS NOT NULL"""EOF
# Run Martinmartin --config martin.tomlMartin will auto-detect your PostGIS tables and serve them at:
http://localhost:3000/tiles/planet_osm_point/{z}/{x}/{y}.pbfhttp://localhost:3000/tiles/planet_osm_line/{z}/{x}/{y}.pbfhttp://localhost:3000/tiles/planet_osm_polygon/{z}/{x}/{y}.pbf
Those .pbf (protocol buffer) files are vector tiles. Your frontend decodes them and renders them in Leaflet or Mapbox GL.
Option 2: pg_tileserv (Simpler)
If Martin feels like overkill, pg_tileserv is lighter:
# Install (Debian packages available)sudo apt-get install pg-tileserv
# Edit /etc/default/pg-tileservDATABASE_URL="postgresql://postgres:password@localhost:5432/osm_maps"PORT=7800
# Startsudo systemctl start pg-tileserv
# Tiles available at# http://localhost:7800/data/planet_osm_polygon/{z}/{x}/{y}.pbfpg_tileserv is slower than Martin but requires zero configuration beyond a connection string.
Serving Tiles on Your Frontend
Once tiles are flowing from Martin or pg_tileserv, drop them into Leaflet:
<!DOCTYPE html><html><head> <link rel="stylesheet" href="https://unpkg.com/@maplibre/maplibre-gl@4/dist/maplibre-gl.css" /> <script src="https://unpkg.com/@maplibre/maplibre-gl@4/dist/maplibre-gl.js"></script> <link rel="stylesheet" href="https://unpkg.com/@maplibre/maplibre-gl-leaflet/dist/maplibre-gl-leaflet.css" /> <script src="https://unpkg.com/@maplibre/maplibre-gl-leaflet/dist/maplibre-gl-leaflet.umd.js"></script> <style> #map { height: 100vh; } </style></head><body> <div id="map"></div> <script> const map = L.map('map').setView([37.7749, -122.4194], 13);
// Add raster background (free source like OSM) L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map);
// Add your vector tiles from Martin L.maplibreGL({ style: { version: 8, sources: { buildings: { type: 'vector', tiles: ['http://localhost:3000/tiles/planet_osm_polygon/{z}/{x}/{y}.pbf'], minzoom: 0, maxzoom: 14 } }, layers: [ { id: 'buildings-fill', type: 'fill', source: 'buildings', paint: { 'fill-color': '#888', 'fill-opacity': 0.6 } }, { id: 'buildings-line', type: 'line', source: 'buildings', paint: { 'fill-outline-color': '#222' } } ] } }).addTo(map); </script></body></html>Pan and zoom. No API keys. No throttling. Just you and your data.
When This Makes Sense (And When It Doesn’t)
Go self-hosted PostGIS if:
- You’re building location features into a self-hosted app (search restaurants, find nearby sensors, store delivery routes)
- You have spatial queries that need speed and you’re willing to run them often
- You need offline-friendly tile caching or restricted geographic access
- Your data’s sensitive (health clinics, supply chain, infrastructure)
- You want zero vendor lock-in
Stick with Google Maps if:
- You need real-time traffic data (PostGIS doesn’t predict the future)
- You need place search (“find ‘Olive Garden’ near me”) without pre-loading every business
- You’re building a consumer product where maintenance burden matters (Google handles it)
- You want turn-by-turn navigation (that’s a routing engine, different beast)
The middle ground (Mapbox, Maplibre Cloud): You control your data in their infrastructure. Good compromise if self-hosting Postgres isn’t your jam.
Real Talk
PostGIS isn’t a plug-and-play “add map to your site” button. It’s raw power, which means you own the setup, the updates, the backups. Think of it like hiring an employee versus hiring a consultant—more upfront work, more ongoing cost, but it’s yours.
The payoff: maps that don’t cost per request, don’t phone home, don’t change pricing on you mid-year. Once you import your region, you’ve got everything you need. Run the queries. Serve the tiles. Sleep easy.
Start with a small region (a country, a state, a city). See if the queries answer your questions. If they do, you’ve cracked it. If they don’t, Postgres has tools you haven’t discovered yet.
Your 2 AM self will appreciate not getting a surprise bill from a mapping API.