Serving OpenStreetMap vector tiles with PostGIS and pg_tileserv

July 31, 2020

map

With the addition of ST_AsMVT() in PostGIS 2.4.0 and the release of pg_tileserv in early 2020, serving Mapbox Vector Tiles from PostgreSQL has become very straightforward. In this post I will show how load OpenStreetMap data into PostgreSQL and serve it as vector tiles.

All code in this post is available at https://github.com/pieterprovoost/osm-tileserver.

Setting up PostgreSQL with PostGIS

I will create three Dockerfiles for this project: one for the PostgreSQL database, a second for populating the database, and the third one for our tile server. This is what the first one looks like:

Dockerfile.postgis
FROM postgres:12

RUN apt-get update
RUN apt-get install -y postgis postgresql-12-postgis-2.5

ADD postgis.sql /docker-entrypoint-initdb.d/

This is based on the official PostgreSQL image and adds PostGIS. The database user is configured in the docker-compose.yml which ties the three images together. You will also need this init script to load the PostGIS extension:

postgis.sql
create schema osm;
alter schema osm owner to osm;
create extension postgis schema osm;

Loading OpenStreetMap data into PostgreSQL

For the second step we need to download some OpenStreetMap data from Geofabrik. In this case I'm working with belgium-latest.osm.pbf. To load OpenStreetMap data into PostgreSQL we can use osm2pgsql. The second Docker image waits for the database to come up before loading the data.

Dockerfile.osm2pgsql
FROM debian

RUN apt-get update
RUN apt-get install -y osm2pgsql

ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.2.1/wait /wait
RUN chmod +x /wait

CMD /wait && osm2pgsql --create -U osm -d osm -H postgis /data/belgium-200101.osm.pbf

Serving vector tiles with pg_tileserv

Our third Docker image runs pg_tileserv. There's no need for any configuration, pg_tileserv will expose any table with a spatial column.

Dockerfile.pgtileserv
FROM debian

RUN apt-get update
RUN apt-get install -y wget unzip

ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.2.1/wait /wait
RUN chmod +x /wait

RUN wget https://postgisftw.s3.amazonaws.com/pg_tileserv_latest_linux.zip
RUN unzip pg_tileserv_latest_linux.zip

ADD pg_tileserv.toml config/

CMD /wait && ./pg_tileserv

Because pg_tileserv has a default limit on the number of features per tile, we add a pg_tileserv config file pg_tileserv.toml to disable this limit:

pg_tileserv.toml
MaxFeaturesPerTile = -1

Once this container is running you can check http://127.0.0.1:7800/ to get the list of available layers:

{
  "osm.planet_osm_line": {
    "type": "table",
    "id": "osm.planet_osm_line",
    "name": "planet_osm_line",
    "schema": "osm",
    "description": "",
    "detailurl": "http://127.0.0.1:7800/osm.planet_osm_line.json"
  },
  "osm.planet_osm_point": {
    "type": "table",
    "id": "osm.planet_osm_point",
    "name": "planet_osm_point",
    "schema": "osm",
    "description": "",
    "detailurl": "http://127.0.0.1:7800/osm.planet_osm_point.json"
  },
  "osm.planet_osm_polygon": {
    "type": "table",
    "id": "osm.planet_osm_polygon",
    "name": "planet_osm_polygon",
    "schema": "osm",
    "description": "",
    "detailurl": "http://127.0.0.1:7800/osm.planet_osm_polygon.json"
  },
  "osm.planet_osm_roads": {
    "type": "table",
    "id": "osm.planet_osm_roads",
    "name": "planet_osm_roads",
    "schema": "osm",
    "description": "",
    "detailurl": "http://127.0.0.1:7800/osm.planet_osm_roads.json"
  }
}

Rendering vector tiles with Mapbox GL JS

We can now render our vector tiles using Mapbox GL JS, a Javascript framework that renders vector tiles using WebGL. First we need to add some data sources our map. Each data source corresponds to one of the pg_tileserv layers listed above. The tile URLs that define our data sources are constructed from the schema and tables we want to target, in this case osm.planet_osm_line and osm.planet_osm_polygon.

const map = new mapboxgl.Map({
    container: "map",
    zoom: 13,
    center: [3.227356, 51.210197]
});

map.addSource("osm_line", {
    "type": "vector",
    "tiles": [ "http://localhost:7800/osm.planet_osm_line/{z}/{x}/{y}.pbf" ]
});

map.addSource("osm_polygon", {
    "type": "vector",
    "tiles": [ "http://localhost:7800/osm.planet_osm_polygon/{z}/{x}/{y}.pbf" ]
});

Next we can add layers to the map. I'm starting with two layers based on the osm.planet_osm_line data source. For aesthetic purposes I'm filtering out some features based on a number of properties including name and operator. I'm also passing some paint properties to each layer to set the line color and opacity.

map.addLayer({
    "id": "lines",
    "type": "line",
    "source": "osm_line",
    "source-layer": "osm.planet_osm_line",
    "filter": [
        "all",
        [ "!=", "power", "cable" ],
        [ "!=", "route", "ferry" ],
        [ "!=", "route", "pipeline" ],
        [ "!=", "man_made", "pipeline" ],
        [ "!=", "operator", "Interoute" ],
        [ "!=", "operator", "Elia" ],
        [ "!=", "operator", "Level 3 Global Submarine" ],
        [ "!=", "operator", "Global Telesystems" ],
        [ "!=", "operator", "GLOBAL CROSSING" ],
        [ "!=", "operator", "Seaborne Freight" ],
        [ "!=", "operator", "BT" ],
        [ "!=", "operator", "KPN TELECOM BV" ],
        [ "!=", "boundary", "administrative" ],
        [ "!=", "boundary", "protected_area" ],
        [ "!=", "waterway", "fairway" ],
        [ "!=", "name", "Sector Groot" ],
        [ "!=", "name", "Sector Midden" ],
        [ "!=", "name", "Sector Klein" ],
        [ "!=", "name", "BRUGG-EEKLN" ]
    ],
    "paint": {
        "line-color": "#777777",
        "line-opacity": 0.4
    }
});

map.addLayer({
    "id": "railway",
    "type": "line",
    "source": "osm_line",
    "source-layer": "osm.planet_osm_line",
    "filter": [ "all", [ "==", "railway", "rail" ]],
    "paint": {
        "line-color": "#d90368",
        "line-opacity": 0.2
    }
});

If you would like to implement server side filtering instead, check out function layers. Finally, I'm adding two polygon layers, one for residential land use and one for buildings.

map.addLayer({
    id: "residential",
    type: "fill",
    source: "osm_polygon",
    "source-layer": "osm.planet_osm_polygon",
    filter: [ "all", [ "==", "landuse", "residential" ]],
    paint: {
        "fill-color": "#7c90a0",
        "fill-opacity": 0.2
    }
});

map.addLayer({
    id: "house",
    type: "fill",
    source: "osm_polygon",
    "source-layer": "osm.planet_osm_polygon",
    filter: [ "all", [ "==", "building", "house" ]],
    paint: {
        "fill-color": "#7c90a0",
        "fill-opacity": 0.5
    }
});

And here's the final result:

map