Skip to content
Go back

PostGIS for Self-Hosted Mapping

By SumGuy 11 min read
PostGIS for Self-Hosted Mapping
Contents

You’re Paying Google to Store Lat/Long Coordinates

Mapping is 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 types: geometry and geography columns, spatial indexes, and vector tile generation in SQL. 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.

Versions checked September 2026: PostGIS 3.5 on Postgres 17, osm2pgsql 2.3.1, Martin v1.15.0, pg_tileserv v1.0.11, MapLibre GL JS 5.24.0.

What You’re Actually Building

Before you get lost in SQL syntax, 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, MapLibre 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 currently supported Postgres (14 through 18, with 17 and 18 being the sensible picks in late 2026), PostGIS is one CREATE EXTENSION away.

Terminal window
# On Ubuntu/Debian. The package is versioned against your Postgres major
# and the PostGIS major, so for Postgres 17 you want:
sudo apt-get install postgresql-17 postgresql-17-postgis-3 postgis
sudo systemctl restart postgresql
sudo -u postgres psql

The postgis package on its own gives you the command line tools (shp2pgsql, raster2pgsql). The extension itself lives in postgresql-<major>-postgis-3. Install only the first one and CREATE EXTENSION postgis fails with “could not open extension control file”.

Inside psql:

setup.sql
CREATE DATABASE osm_maps;
\c osm_maps
CREATE EXTENSION postgis;
SELECT PostGIS_version();

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, and you should look at the file sizes before you pick a region. As of September 2026 the Geofabrik North America extract is 19.3 GB compressed. Grab your actual region from Geofabrik, or cut a smaller bounding box yourself with osmium extract.

Terminal window
sudo apt-get install osm2pgsql
# Start small. A single US state or a country is usually 100 MB to 2 GB.
wget https://download.geofabrik.de/north-america/us/california-latest.osm.pbf
osm2pgsql \
--database osm_maps \
--user postgres \
--host localhost \
--create \
--slim \
--log-progress=true \
--number-processes=4 \
california-latest.osm.pbf

A city or small country imports in minutes. A US state takes tens of minutes to a couple of hours. A full North America import is an overnight job that wants an SSD, tens of gigabytes of free space, and --flat-nodes to keep the node cache off the heap. Do not plan your evening around it.

osm2pgsql will:

Two notes on that list. The --style flag defaults to /usr/share/osm2pgsql/default.style for this output, so you can leave it off. And the output producing those planet_osm_* tables is the pgsql output, which is still the default but has been formally deprecated since osm2pgsql 2.0.0. It works fine today and the tables above are what you’ll get. If you’re building something you intend to maintain for years, read up on the flex output and its Lua config before you commit to this schema.

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, with one trap that will bite you in the first thirty seconds.

Your geometries are in Web Mercator, not lon/lat. The way column is EPSG:3857, so way::geography does not work and neither does comparing way against a 4326 envelope:

Terminal window
-- way::geography
ERROR: Only lon/lat coordinate systems are supported in geography.
-- ST_Intersects(way, ST_MakeEnvelope(..., 4326))
ERROR: ST_Intersects: Operation on mixed SRID geometries (Point, 3857) != (Polygon, 4326)

Every copy-pasted PostGIS snippet on the internet assumes 4326. Transform explicitly and the errors go away:

spatial-queries.sql
-- Coffee shops within 5km of a point. Transform `way` to 4326 and cast to
-- geography so the 5000 is real meters, not Mercator units.
SELECT name, amenity
FROM planet_osm_point
WHERE amenity = 'cafe'
AND ST_DWithin(
ST_Transform(way, 4326)::geography,
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography,
5000
)
LIMIT 20;
-- Buildings in a bounding box. Transform the envelope INTO 3857 so the
-- comparison happens in the column's own SRID and uses its index.
SELECT COUNT(*) AS building_count
FROM planet_osm_polygon
WHERE building IS NOT NULL
AND ST_Intersects(
way,
ST_Transform(ST_MakeEnvelope(-122.5, 37.7, -122.4, 37.8, 4326), 3857)
);
-- Nearest hospital. Order by the KNN operator (<->) so the index does the
-- work, then compute the real distance for display.
SELECT name,
ST_Distance(
ST_Transform(way, 4326)::geography,
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography
) / 1000 AS distance_km
FROM planet_osm_point
WHERE amenity = 'hospital'
ORDER BY way <-> ST_Transform(ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326), 3857)
LIMIT 1;

Note the direction of each transform. Put it on the envelope or the search point where you can, because wrapping way in ST_Transform means Postgres computes it for every row and skips the GiST index on the column. When you do need geography distances at scale, index the expression once:

functional-index.sql
CREATE INDEX planet_osm_point_geog_idx
ON planet_osm_point
USING GIST ((ST_Transform(way, 4326)::geography));

One more small thing: ST_AsText(way) prints Mercator meters (POINT(-13627665.27 4547675.35)), not the coordinates you recognize. Wrap it in ST_Transform(way, 4326) when you want something human readable.

Generating Tiles: Martin or pg_tileserv

You’ve got data. Now you need something to turn it into tiles on demand.

Option 1: Martin (Preferred)

Martin is a Rust tile server from the MapLibre organization. It’s fast, actively released, and auto-publishes any table with a geometry column.

Terminal window
curl -L -O https://github.com/maplibre/martin/releases/latest/download/martin-x86_64-unknown-linux-gnu.tar.gz
tar xzf martin-x86_64-unknown-linux-gnu.tar.gz
sudo mv martin /usr/local/bin/martin
# Simplest possible start: no config file at all.
martin postgresql://postgres:password@localhost:5432/osm_maps

When you want to control what gets exposed, the config file is YAML, not TOML, and the connection lives under a postgres: key:

config.yaml
listen_addresses: 0.0.0.0:3000
postgres:
connection_string: postgresql://postgres:password@localhost:5432/osm_maps
# Discover tables and functions automatically. Set to false and list
# `tables:` explicitly to publish only what you want.
auto_publish: true
pool_size: 20
Terminal window
martin --config config.yaml

There is no sql = "..." key for defining a custom layer inline, which is what a lot of older blog posts show. Martin publishes tables, views, and PostGIS functions that return bytea. For a custom layer, create a view or write a tile function and let auto-publish pick it up. martin --save-config config.yaml will dump everything it discovered so you can delete the parts you don’t want.

Martin serves each source at the source ID, which for a table is the table name. No /tiles/ prefix and no file extension:

Those responses are Mapbox Vector Tiles. Your frontend decodes them and renders them in MapLibre GL.

Option 2: pg_tileserv (Simpler)

pg_tileserv is CrunchyData’s Go tile server, and it is a smaller thing to operate: one binary, one environment variable. It is not packaged in Debian or Ubuntu, so ignore any guide telling you to apt-get install pg-tileserv. Grab the binary:

Terminal window
curl -L -o pg_tileserv.zip https://postgisftw.s3.amazonaws.com/pg_tileserv_latest_linux.zip
unzip pg_tileserv.zip
sudo mv pg_tileserv /usr/local/bin/
export DATABASE_URL="postgresql://postgres:password@localhost:5432/osm_maps"
pg_tileserv
# Tiles at http://localhost:7800/public.planet_osm_polygon/{z}/{x}/{y}.pbf

Worth knowing before you pick it: the last tagged release is v1.0.11 from February 2024. The repo still sees commits, but Martin is where the active development is. Choose pg_tileserv for the smaller operational surface, not because you expect new features.

Serving Tiles on Your Frontend

Now the frontend. One version trap here too: MapLibre GL JS 6.x ships ESM only, so a plain <script src> tag against v6 gets you nothing. For a script-tag page, pin v5:

map.html
<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/[email protected]/dist/maplibre-gl.css" />
<script src="https://unpkg.com/[email protected]/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/@maplibre/[email protected]/leaflet-maplibre-gl.js"></script>
<style>
#map { height: 100vh; }
</style>
<div id="map"></div>
<script>
const map = L.map('map').setView([37.7749, -122.4194], 13);
// Raster background. Note: tile.openstreetmap.org is a donated service
// with a strict usage policy. Fine while you develop, not fine for a
// public site. Run your own raster tiles or use a paid basemap.
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
// Your vector tiles from Martin.
L.maplibreGL({
style: {
version: 8,
sources: {
buildings: {
type: 'vector',
tiles: ['http://localhost:3000/planet_osm_polygon/{z}/{x}/{y}'],
minzoom: 0,
maxzoom: 14
}
},
layers: [
{
id: 'buildings-fill',
type: 'fill',
source: 'buildings',
'source-layer': 'planet_osm_polygon',
paint: { 'fill-color': '#888', 'fill-opacity': 0.6 }
},
{
id: 'buildings-line',
type: 'line',
source: 'buildings',
'source-layer': 'planet_osm_polygon',
paint: { 'line-color': '#222', 'line-width': 1 }
}
]
}
}).addTo(map);
</script>

The source-layer property is the one people forget. A vector tile can carry several named layers, and a style layer that doesn’t name one renders nothing at all, silently. For Martin table sources the layer name matches the source ID.

Pan and zoom. No API keys. No throttling.

When This Makes Sense (And When It Doesn’t)

Go self-hosted PostGIS if:

Stick with Google Maps if:

The middle ground (Mapbox, MapTiler Cloud, Protomaps): you keep control of your styling and data model while somebody else runs the tile infrastructure. A reasonable compromise if hosting Postgres isn’t your jam.

Common Questions

Why does my PostGIS query fail with “Only lon/lat coordinate systems are supported in geography”?

Because osm2pgsql stores the way column in EPSG:3857 (Web Mercator) by default, and the geography type only accepts lon/lat. Wrap the column as ST_Transform(way, 4326)::geography, or import with --latlong to store 4326 in the first place.

How much disk and time does an OSM import actually take?

Depends entirely on the extract. A city is minutes and hundreds of megabytes. A US state is tens of minutes to a couple of hours. The Geofabrik North America extract is 19.3 GB compressed as of September 2026 and imports overnight, wanting an SSD plus --flat-nodes. Start with one state.

Can I use tile.openstreetmap.org as the basemap on my public site?

No. The OSM Foundation’s tile servers are donated infrastructure with a usage policy that rules out bulk and commercial use. They’re fine while you develop. For anything public, serve your own raster tiles from your PostGIS data or pay a basemap provider.

Should I pick Martin or pg_tileserv?

Martin, in most cases. It gets active releases (v1.15.0 in September 2026), auto-publishes tables and PostGIS functions, and handles MBTiles and PMTiles sources too. pg_tileserv is a smaller binary with one environment variable of config, but its last tagged release was February 2024.

Do I need PostGIS at all if I only store a few thousand coordinates?

No. A lat and lon pair of double precision columns plus a bounding-box WHERE clause is fine for a few thousand rows. PostGIS earns its keep when you need real distance maths across projections, polygon operations, or vector tile output. Adding it for two columns is overkill.


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.


Previous Post
SQLite Replication: Litestream and rqlite
Next Post
pgvector for Local Embeddings

Discussion

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

Related Posts