Skip to content
Go back

Map Sovereignty - Self-Hosted Vector Tiles with Protomaps and PMTiles

Published:  at  07:00 PM

The Problem

Geographic applications that run for decades share a recurring weakness: external map tile providers. Google Maps, Mapbox, HERE, they’re excellent until they aren’t.

The issues accumulate over the years:

The Solution

A self-hosted vector tile stack built on PMTiles and Protomaps. Two technologies that together make self-hosting maps genuinely simple:

Maps

Protomaps Daily Builds (.pmtiles)

go-pmtiles (serve archive)

Nginx (proxy + static files)

MapLibre GL JS (render in browser)

No tile generation. No database. No PostGIS. No import pipelines. One file. One server. Done.

Why PMTiles?

PMTiles is an archive format for tiled data, like a read-only SQLite for maps. A single .pmtiles file contains an entire planet (or region) worth of vector tiles, indexed for random access via HTTP Range requests.

This means:

Why Protomaps?

Protomaps provides builds of the entire planet as PMTiles archives, derived from OpenStreetMap data. You download a file, you have the world.

The Architecture

Two Docker containers only: Pmtiles serves the archive via the PMTiles HTTP API. webserver serves static files (styles, sprites, fonts) and proxies tile requests.

# docker-compose.yml
services:
  pmtiles:
    image: protomaps/go-pmtiles
    container_name: pmtiles-server
    command: serve /data
    restart: always
    volumes:
      - ./data:/data

  webserver:
    image: nginx:alpine
    container_name: nginx-protomaps
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./conf-sites/nginx.conf:/etc/nginx/nginx.conf
      - ./conf-sites/sites.conf:/etc/nginx/conf.d/default.conf
      - ./public:/usr/share/nginx/html:ro
    depends_on:
      - pmtiles

Nginx Configuration

Nginx is tuned for serving map tiles efficiently — gzip on protobuf, open file caching, reduced logging:

# nginx.conf
user  nginx;
worker_processes auto;

events {
    worker_connections 4096;
    multi_accept on;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;

    keepalive_timeout 65;
    keepalive_requests 1000;

    client_body_buffer_size 128k;
    client_max_body_size 0;

    access_log off;

    gzip on;
    gzip_min_length 1024;
    gzip_types application/json application/javascript text/css application/x-protobuf;

    open_file_cache max=200000 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;

    include /etc/nginx/conf.d/*.conf;
}

The site config proxies /tiles/ to go-pmtiles with CORS headers:

# sites.conf
server {
    listen 80;
    server_name maps.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name maps.example.com;

    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ =404;
    }

    location /tiles/ {
        proxy_pass http://pmtiles:8080/;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;

        ...
    }
}

Map Styles

Four themes, all referencing the same PMTiles source, just different paint properties:

{
  "version": 8,
  "sources": {
    "protomaps": {
      "type": "vector",
      "tiles": ["https://maps.example.com/tiles/20260416/{z}/{x}/{y}.mvt"],
      "minzoom": 0,
      "maxzoom": 14
    }
  },
  "layers": [
    {
      "id": "background",
      "type": "background",
      "paint": { "background-color": "#34373d" }
    },
    {
      "id": "earth",
      "type": "fill",
      "source": "protomaps",
      "source-layer": "earth",
      "paint": { "fill-color": "#1f1f1f" }
    },
    {
      "id": "water",
      "type": "fill",
      "source": "protomaps",
      "source-layer": "water",
      "paint": { "fill-color": "#223143" }
    },
    ...
  ]
}

Each style file (light.json, dark.json, white.json, black.json) has roughly 12,000 lines and covers all layers, including land use, roads, buildings, waterways, POIs, transit, boundaries and typography. The structure is identical across themes; only the colors and text styling change.

You can download a standard template here: https://maps.protomaps.com/#flavorName=light&lang=en&map=1.19/0/0

How to Use

Just point your map to one of your self-hosted style files:

const center = [-8.70, 41.18];

const map = new maplibregl.Map({
    container: 'map',
    style: 'https://maps.example.com/light.json', // or dark.json, white.json, black.json
    center: center,
    zoom: 13,
    attributionControl: false
});

Automated Daily Updates

The update.sh script downloads the latest Protomaps daily build, creates an atomic symlink, and cleans up old versions:

#!/bin/bash
set -euo pipefail

BASE_URL="https://build.protomaps.com"
DATA_DIR="/opt/protomaps/data"
LOCK_FILE="/tmp/protomaps-update.lock"
MAX_RETRIES=3
RETRY_DELAY=10

if [ -f "$LOCK_FILE" ]; then
    echo "Another update is running. Exiting."
    exit 1
fi
trap "rm -f $LOCK_FILE" EXIT
touch "$LOCK_FILE"

cd "$DATA_DIR"
TODAY=$(date +%Y%m%d).pmtiles

# Download with retry
attempt=1
while [ $attempt -le $MAX_RETRIES ]; do
    echo "Downloading $TODAY (attempt $attempt)"
    rm -f "$TODAY"
    if wget -q "$BASE_URL/$TODAY"; then
        echo "Download successful"
        break
    fi
    echo "Failed, retrying in ${RETRY_DELAY}s..."
    sleep $RETRY_DELAY
    attempt=$((attempt + 1))
done

# Atomic symlink swap
ln -sfn "$TODAY" map.pmtiles

# Keep only last 3 versions
ls -1t *.pmtiles | tail -n +4 | xargs -r rm -f

echo "Update completed: map.pmtiles -> $TODAY"

Set it as a cron job and your maps update automatically:

# Every day at 3 AM
0 3 * * * /opt/protomaps/scripts/update.sh >> /var/log/protomaps-update.log 2>&1

One-Time Asset Setup

Sprites and fonts are global — download once:

#!/bin/bash
set -euo pipefail

ASSETS_URL="https://raw.githubusercontent.com/protomaps/basemaps-assets/main"
SPRITES_API="https://api.github.com/repos/protomaps/basemaps-assets/contents/sprites/v4"
PUBLIC_DIR="/opt/protomaps/public"

mkdir -p "$PUBLIC_DIR/sprites"
mkdir -p "$PUBLIC_DIR/fonts"

# Sprites (all themes)
curl -s "$SPRITES_API" | jq -r '.[].download_url' | while read -r url; do
    file=$(basename "$url")
    curl -s -L --fail --retry 3 -o "$PUBLIC_DIR/sprites/$file" "$url"
    echo "OK $file"
done

# Fonts (Noto Sans - covers Latin, CJK, Arabic, etc.)
declare -A FONTS=(
    ["NotoSansRegular"]="Noto%20Sans%20Regular"
    ["NotoSansMedium"]="Noto%20Sans%20Medium"
    ["NotoSansItalic"]="Noto%20Sans%20Italic"
)

for local_name in "${!FONTS[@]}"; do
    remote_name=${FONTS[$local_name]}
    mkdir -p "$PUBLIC_DIR/fonts/$local_name"

    for i in {0..255}; do
        start=$((i * 256))
        end=$((start + 255))
        range="$start-$end.pbf"
        target="$PUBLIC_DIR/fonts/$local_name/$range"

        if [ ! -f "$target" ]; then
            curl -s -L --fail --retry 3 \
                -o "$target" \
                "$ASSETS_URL/fonts/$remote_name/$range" || true
        fi
    done
    echo "OK $local_name completed"
done

Directory Structure

4maps/
├── docker-compose.yml
├── conf-sites/
│   ├── nginx.conf
│   ├── sites.conf
│   └── certs/
│       ├── cert.pem
│       └── priv.pem
├── data/
│   └── 20260416.pmtiles
├── scripts/
│   ├── update.sh
│   └── update-assets.sh
└── public/
    ├── light.json
    ├── dark.json
    ├── white.json
    ├── black.json
    ├── sprites/
    │   ├── light.json
    │   ├── light.png
    │   ├── light@2x.json
    │   └── ...
    └── fonts/
        ├── NotoSansRegular/
        ├── NotoSansMedium/
        └── ...

Conclusion

Applications designed to last decades can’t depend on map providers whose business models shift quarterly. The Protomaps + PMTiles + MapLibre stack has reduced what used to require a full tile rendering pipeline (PostGIS, imposm, tilelive, etc.) to one file, two containers, and a cron job.

Your maps. Your server. Your rules.



Next Post
Scalable Multi-Language System - Bridging Laravel and Vue-i18n