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:
- 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, 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.
# 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 postgresqlsudo -u postgres psqlThe 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:
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.
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.pbfA 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:
- Create
planet_osm_point,planet_osm_line,planet_osm_polygonandplanet_osm_roadstables - Build spatial indexes
- Turn OSM tags into columns (
shop,amenity,highway, and friends) - Store geometries in the
waycolumn, projected to Web Mercator (EPSG:3857) by default
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:
-- way::geographyERROR: 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:
-- 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, amenityFROM planet_osm_pointWHERE 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_countFROM planet_osm_polygonWHERE 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_kmFROM planet_osm_pointWHERE 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:
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.
curl -L -O https://github.com/maplibre/martin/releases/latest/download/martin-x86_64-unknown-linux-gnu.tar.gztar xzf martin-x86_64-unknown-linux-gnu.tar.gzsudo mv martin /usr/local/bin/martin
# Simplest possible start: no config file at all.martin postgresql://postgres:password@localhost:5432/osm_mapsWhen you want to control what gets exposed, the config file is YAML, not TOML, and the connection lives under a postgres: key:
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: 20martin --config config.yamlThere 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:
http://localhost:3000/planet_osm_point/{z}/{x}/{y}http://localhost:3000/planet_osm_line/{z}/{x}/{y}http://localhost:3000/planet_osm_polygon/{z}/{x}/{y}
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:
curl -L -o pg_tileserv.zip https://postgisftw.s3.amazonaws.com/pg_tileserv_latest_linux.zipunzip pg_tileserv.zipsudo 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}.pbfWorth 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:
<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: '© 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:
- 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, a different beast)
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.