Skip to content
Go back

PostGIS for Self-Hosted Mapping

By SumGuy 7 min read
PostGIS for Self-Hosted Mapping

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:

  1. PostgreSQL + PostGIS extension — the database that understands “points on Earth”
  2. OSM data (via osm2pgsql) — planet data sliced down to your region of interest, loaded into tables
  3. Spatial queries — “find all restaurants within 2km of this point” runs as fast as your indexes
  4. Tile server (Martin or pg_tileserv) — HTTP endpoint that returns map tiles on demand
  5. 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.

Terminal window
# On Ubuntu/Debian, install the postgis package
sudo apt-get install postgresql postgresql-contrib postgis postgresql-<version>-postgis
# Start or restart Postgres
sudo systemctl restart postgresql
# Connect as the postgres user
sudo -u postgres psql

Inside psql:

-- Create your maps database
CREATE DATABASE osm_maps;
-- Connect to it
\c osm_maps
-- Enable PostGIS
CREATE EXTENSION postgis;
-- Check it worked
SELECT 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.

Terminal window
# Install osm2pgsql
sudo apt-get install osm2pgsql
# Download a region extract (e.g., North America)
wget https://download.geofabrik.de/north-america-latest.osm.pbf
# Import into Postgres
osm2pgsql \
--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.pbf

Wait 30 minutes to a few hours depending on region size. osm2pgsql will:

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 meters
SELECT name, amenity,
ST_AsText(way) AS location
FROM planet_osm_point
WHERE 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 box
SELECT COUNT(*) AS building_count
FROM planet_osm_polygon
WHERE 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 point
SELECT name, amenity,
ST_Distance(way::geography, ST_MakePoint(-122.4194, 37.7749)::geography) / 1000 AS distance_km
FROM planet_osm_point
WHERE amenity = 'hospital'
ORDER BY distance_km ASC
LIMIT 1;
-- Get all street names in a polygon (e.g., your neighborhood)
SELECT DISTINCT name
FROM planet_osm_line
WHERE 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.

Terminal window
# Install Martin (from releases or cargo)
wget https://github.com/maplibre/martin/releases/download/v0.14.0/martin-x86_64-unknown-linux-gnu
chmod +x martin-x86_64-unknown-linux-gnu
sudo mv martin-x86_64-unknown-linux-gnu /usr/local/bin/martin
# Create config
cat > martin.toml << 'EOF'
# martin.toml
bind_address = "0.0.0.0:3000"
[postgresql]
connection_string = "postgresql://postgres:password@localhost:5432/osm_maps"
# Auto-detect all tables with geometry
auto_detect = true
# Custom layer definitions (optional)
[[tiles]]
id = "buildings"
sql = """
SELECT way, building, name
FROM planet_osm_polygon
WHERE building IS NOT NULL
"""
EOF
# Run Martin
martin --config martin.toml

Martin will auto-detect your PostGIS tables and serve them at:

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:

Terminal window
# Install (Debian packages available)
sudo apt-get install pg-tileserv
# Edit /etc/default/pg-tileserv
DATABASE_URL="postgresql://postgres:password@localhost:5432/osm_maps"
PORT=7800
# Start
sudo systemctl start pg-tileserv
# Tiles available at
# http://localhost:7800/data/planet_osm_polygon/{z}/{x}/{y}.pbf

pg_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/[email protected]/dist/leaflet.css" />
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<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:

Stick with Google Maps if:

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.


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