• Cartographie
  • OpenStreetMap
  • Docker
  • Traefik
  • MapLibre

Héberger ses propres tuiles vectorielles OpenStreetMap avec Docker et Traefik

Tutoriel : générer des tuiles vectorielles OpenStreetMap avec Planetiler, les servir avec Martin et Docker derrière Traefik en HTTPS, les afficher avec MapLibre.

Les données OpenStreetMap passent par Planetiler, qui produit un fichier PMTiles ; Martin le sert dans Docker, derrière Traefik en HTTPS, et MapLibre dessine la carte dans le navigateurOpenStreetMapPlanetilerfrance.pmtilesDockerMartinTuilesPolicesStyleTraefikHTTPSapp.example.orgToulouse© OpenStreetMap

Afficher une carte dans une application web ne demande pas de dépendre d’un fournisseur de cartes. Avec des tuiles vectorielles générées à partir d’OpenStreetMap et servies par votre propre serveur de tuiles, vous maîtrisez les coûts, la disponibilité et les données : aucune requête de vos utilisateurs ne part chez un tiers.

C’est le principe de G-ordon Planisphère, la cartographie de notre logiciel G-ordon : des cartes issues d’OpenStreetMap, servies par nos propres serveurs. Ce tutoriel montre une façon de le faire, de bout en bout, avec des outils libres :

  1. Planetiler génère les tuiles à partir des données OpenStreetMap ;
  2. Martin les sert, avec les polices et le style, dans un conteneur Docker ;
  3. Traefik les publie en HTTPS, avec un certificat Let’s Encrypt ;
  4. MapLibre GL JS les affiche dans le navigateur.

Tuiles raster ou tuiles vectorielles ?

Une tuile raster est une image : le serveur dessine la carte, le navigateur l’affiche. Une tuile vectorielle transporte des données (routes, bâtiments, noms de lieux) que le navigateur dessine lui-même, selon un style.

Tuiles rasterTuiles vectorielles
Ce qui circuleDes images PNG ou JPEGDes géométries et des attributs
RenduSur le serveurDans le navigateur
Changer l’apparenceRégénérer toutes les tuilesModifier le style, rien d’autre
Volume à hébergerÉlevé (une image par niveau de zoom)Nettement plus faible
NettetéPixelisée entre deux niveauxNette à tous les zooms, rotation possible

Pour une application métier, le vectoriel s’impose : un seul jeu de tuiles sert plusieurs styles (clair, sombre, impression), et les étiquettes restent lisibles à tous les zooms.

L’architecture cible

Schéma
Navigateur (MapLibre GL JS)
        │  HTTPS
        ▼
Traefik  ── certificat Let's Encrypt, en-têtes de cache, CORS
        │  réseau Docker interne
        ▼
Martin   ── tuiles (PMTiles), polices (glyphes), style
        │
        ▼
Fichier france.pmtiles, généré par Planetiler

Seul Traefik est exposé sur internet (ports 80 et 443). Martin n’écoute que sur le réseau Docker interne.

Étape 1 : générer les tuiles avec Planetiler

Planetiler télécharge un extrait OpenStreetMap (fourni par Geofabrik) et produit les tuiles selon le schéma OpenMapTiles, le plus répandu. L’extension du fichier de sortie choisit le format : .pmtiles, un fichier unique facile à déplacer et à servir.

Commencez par un petit territoire pour valider la chaîne, Monaco par exemple :

Terminal · essai sur Monaco
mkdir -p data
docker run --rm \
  -e JAVA_TOOL_OPTIONS="-Xmx2g" \
  -v "$(pwd)/data":/data \
  ghcr.io/onthegomap/planetiler:latest \
  --download --area=monaco --output=/data/monaco.pmtiles

Puis passez à votre territoire, la France par exemple :

Terminal · la France
docker run --rm \
  -e JAVA_TOOL_OPTIONS="-Xmx8g" \
  -v "$(pwd)/data":/data \
  ghcr.io/onthegomap/planetiler:latest \
  --download --area=france --output=/data/france.pmtiles --force

Quelle machine pour la génération ?

La génération est le seul moment gourmand de toute la chaîne : servir les tuiles demande très peu de ressources. La bonne pratique est donc de louer une machine puissante le temps de la génération, puis de la supprimer, et de copier le fichier PMTiles sur le serveur qui le sert. Chez Scaleway, notre hébergeur, deux familles s’y prêtent :

Machine ScalewayMémoireDisquePour quoi faire
Instance POP2-HM-8C-64G64 Go, 8 vCPUVolume Block à ajouterUne région ou un pays, une génération de temps en temps
Instance POP2-HM-16C-128G128 Go, 16 vCPUVolume Block à ajouterPlusieurs pays, l’Europe
Elastic Metal EM-I210E-NVMe128 Go, 16 cœurs2 × 1,92 To NVMeDes générations fréquentes ou de grandes zones
Elastic Metal EM-B520E-NVMe256 Go2 × 2 To NVMeLa planète entière
  • Les Instances (gamme Scaleway Elements) sont facturées à l’heure : une POP2-HM-8C-64G coûte 0,41 € HT de l’heure selon la grille publique de Scaleway, de quoi générer un pays pour quelques euros. Ajoutez un volume Block SSD assez grand pour l’extrait et les fichiers de travail.
  • Les serveurs Elastic Metal sont des machines physiques dédiées, avec des disques NVMe locaux : Planetiler écrit beaucoup de fichiers temporaires, et un disque rapide raccourcit nettement la génération.

Les gammes et les prix évoluent : vérifiez-les sur la page des tarifs Scaleway avant de choisir.

Étape 2 : servir les tuiles avec Martin

Martin est le serveur de tuiles du projet MapLibre. Il sert des fichiers PMTiles ou MBTiles, mais aussi les polices (glyphes) dont le navigateur a besoin pour écrire les noms de lieux, les sprites (icônes) et les styles.

Les polices

Les étiquettes d’une carte vectorielle sont dessinées avec des glyphes servis par le serveur. Martin les produit à partir de fichiers de police classiques : placez par exemple les fichiers .ttf de Noto Sans (licence OFL) dans un dossier fonts/. Chaque police est ensuite désignée par sa famille et sa graisse, par exemple Noto Sans Regular.

Le fichier de configuration

martin/config.yaml
# martin/config.yaml
listen_addresses: 0.0.0.0:3000

# Seule l'application a le droit d'appeler le serveur depuis un navigateur.
cors:
  origin:
    - https://app.example.org
  max_age: 3600

pmtiles:
  sources:
    france: /data/france.pmtiles

fonts:
  paths:
    - /fonts

styles:
  sources:
    base: /styles/base.json

Martin publie alors :

AdresseContenu
/catalogLa liste des sources
/franceLa description TileJSON de la source
/france/{z}/{x}/{y}Les tuiles
/font/{police}/{début}-{fin}Les glyphes d’une police
/style/baseLe style de la carte

Étape 3 : un style minimal

Le style décrit, couche par couche, comment dessiner les données. Les noms de couches (water, transportation, building, place…) sont ceux du schéma OpenMapTiles. Voici un style volontairement court, à enrichir :

styles/base.json
{
  "version": 8,
  "name": "Base",
  "glyphs": "https://tiles.example.org/font/{fontstack}/{range}",
  "sources": {
    "openmaptiles": {
      "type": "vector",
      "url": "https://tiles.example.org/france",
      "attribution": "© OpenMapTiles © Contributeurs OpenStreetMap"
    }
  },
  "layers": [
    { "id": "background", "type": "background",
      "paint": { "background-color": "#f4f1ea" } },
    { "id": "water", "type": "fill", "source": "openmaptiles", "source-layer": "water",
      "paint": { "fill-color": "#a8c8e8" } },
    { "id": "park", "type": "fill", "source": "openmaptiles", "source-layer": "park",
      "paint": { "fill-color": "#d5e8c4" } },
    { "id": "building", "type": "fill", "source": "openmaptiles", "source-layer": "building",
      "minzoom": 14, "paint": { "fill-color": "#e0dbd2" } },
    { "id": "roads", "type": "line", "source": "openmaptiles", "source-layer": "transportation",
      "paint": {
        "line-color": "#ffffff",
        "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 16, 6]
      } },
    { "id": "places", "type": "symbol", "source": "openmaptiles", "source-layer": "place",
      "layout": {
        "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
        "text-font": ["Noto Sans Regular"],
        "text-size": 13
      },
      "paint": { "text-color": "#333333", "text-halo-color": "#ffffff", "text-halo-width": 1.5 } }
  ]
}

Pour un rendu complet sans partir de zéro, des styles libres conçus pour le schéma OpenMapTiles existent (OSM Bright, Positron…) : il suffit d’y remplacer les adresses des tuiles et des glyphes par les vôtres, et de vérifier leur licence.

Étape 4 : publier en HTTPS avec Traefik

Traefik découvre les conteneurs Docker, obtient les certificats Let’s Encrypt et applique des règles déclarées par des labels. Voici le docker-compose.yml complet :

docker-compose.yml
# docker-compose.yml
services:
  traefik:
    image: traefik:${TRAEFIK_VERSION}
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --certificatesresolvers.le.acme.tlschallenge=true
      - --certificatesresolvers.le.acme.email=${ACME_EMAIL}
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    restart: unless-stopped

  martin:
    image: ghcr.io/maplibre/martin:${MARTIN_VERSION}
    command: ["--config", "/config/config.yaml"]
    volumes:
      - ./martin:/config:ro
      - ./data:/data:ro
      - ./fonts:/fonts:ro
      - ./styles:/styles:ro
    labels:
      - traefik.enable=true
      - traefik.http.services.tiles.loadbalancer.server.port=3000

      # Les tuiles : changent une fois par mois, gardées un jour, resservies une semaine
      # pendant leur revalidation.
      - traefik.http.routers.tiles.rule=Host(`tiles.example.org`)
      - traefik.http.routers.tiles.entrypoints=websecure
      - traefik.http.routers.tiles.tls.certresolver=le
      - traefik.http.routers.tiles.service=tiles
      - traefik.http.routers.tiles.middlewares=tiles-gzip,cache-tiles

      # Les polices : ne changent jamais, gardées un mois.
      - traefik.http.routers.fonts.rule=Host(`tiles.example.org`) && PathPrefix(`/font`)
      - traefik.http.routers.fonts.entrypoints=websecure
      - traefik.http.routers.fonts.tls.certresolver=le
      - traefik.http.routers.fonts.service=tiles
      - traefik.http.routers.fonts.middlewares=tiles-gzip,cache-fonts

      # Le style et les descriptions : changent quand vous les retouchez, gardés cinq minutes.
      - traefik.http.routers.style.rule=Host(`tiles.example.org`) && (PathPrefix(`/style`) || Path(`/catalog`) || Path(`/france`))
      - traefik.http.routers.style.entrypoints=websecure
      - traefik.http.routers.style.tls.certresolver=le
      - traefik.http.routers.style.service=tiles
      - traefik.http.routers.style.middlewares=tiles-gzip,cache-style

      # Compression gzip (et Brotli pour les navigateurs qui la demandent).
      - traefik.http.middlewares.tiles-gzip.compress=true
      - traefik.http.middlewares.tiles-gzip.compress.encodings=gzip,br

      # En-têtes de cache, un par type de contenu.
      - traefik.http.middlewares.cache-tiles.headers.customresponseheaders.Cache-Control=public, max-age=86400, stale-while-revalidate=604800
      - traefik.http.middlewares.cache-fonts.headers.customresponseheaders.Cache-Control=public, max-age=2592000, immutable
      - traefik.http.middlewares.cache-style.headers.customresponseheaders.Cache-Control=public, max-age=300
    restart: unless-stopped

Et le fichier .env qui l’accompagne :

.env
# .env : épinglez des versions précises, publiées et testées.
TRAEFIK_VERSION=v3.x.y
MARTIN_VERSION=x.y.z
ACME_EMAIL=[email protected]

Démarrez l’ensemble :

Terminal
docker compose up -d
curl -sI https://tiles.example.org/catalog

La compression et le cache, réglés par type de contenu

Trois routeurs pointent vers le même service Martin. Traefik choisit le plus précis : une adresse en /font/… prend le routeur fonts, une adresse en /style/… le routeur style, et tout le reste, c’est-à-dire les tuiles, le routeur tiles. Chacun reçoit sa durée de cache :

ContenuDurée de cachePourquoi
Tuiles /france/{z}/{x}/{y}Un jour, resservies une semaine pendant la revalidationElles ne changent qu’à la mise à jour mensuelle
Polices /font/…Un mois, immutableUn glyphe ne change jamais
Style, TileJSON, catalogueCinq minutesUne retouche du style doit se voir vite

La directive stale-while-revalidate permet au navigateur, ou à un CDN, d’afficher tout de suite une tuile un peu ancienne pendant qu’il en vérifie la fraîcheur : la carte ne reste jamais vide.

Pour vérifier :

Terminal
# Une tuile : déjà compressée par Martin, avec son cache d'un jour.
curl -sI -H "Accept-Encoding: gzip, br" https://tiles.example.org/france/12/2064/1495 \
  | grep -iE "content-encoding|cache-control"

# Le style : compressé par Traefik, cinq minutes de cache.
curl -sI -H "Accept-Encoding: gzip, br" https://tiles.example.org/style/base \
  | grep -iE "content-encoding|cache-control"

Quatre points de sécurité à ne pas manquer :

  1. Martin reste en coulisse

    Le risque

    Publier le port de Martin le rendrait joignable directement, sans HTTPS ni en-têtes de cache.

    La parade

    Pas de section ports : seul Traefik le joint, sur le réseau Docker. Seuls les ports 80 et 443 sont ouverts sur le serveur.

  2. Le socket Docker est puissant

    Le risque

    Il donne à Traefik une vue sur tous les conteneurs du serveur.

    La parade

    Il est monté en lecture seule ; pour aller plus loin, un proxy de socket limite ce que Traefik peut demander au démon Docker.

  3. Rien n'est publié par défaut

    Le risque

    Sans exposedbydefault=false, tout nouveau conteneur serait exposé sur internet.

    La parade

    Seuls les conteneurs qui portent traefik.enable=true sont publiés.

  4. Le tableau de bord reste fermé

    Le risque

    Le tableau de bord de Traefik révèle les routes, les services et les certificats.

    La parade

    Il n’est pas activé ici ; s’il l’est, protégez-le par une authentification et un accès restreint.

Étape 5 : afficher la carte avec MapLibre GL JS

Installez la bibliothèque dans votre application, plutôt que de la charger depuis un CDN tiers : c’est la logique de l’auto-hébergement jusqu’au bout.

Terminal
npm install maplibre-gl
map.js
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";

const map = new maplibregl.Map({
  container: "map",
  style: "https://tiles.example.org/style/base",
  center: [1.444, 43.604], // Toulouse
  zoom: 12,
});

map.addControl(new maplibregl.NavigationControl());

La carte s’affiche, avec l’attribution en bas à droite. Changer d’apparence revient désormais à modifier base.json, sans toucher aux tuiles.

Mettre à jour les données

OpenStreetMap évolue chaque jour. Une mise à jour mensuelle suffit pour la plupart des usages métier.

update-tiles.sh
#!/usr/bin/env sh
# update-tiles.sh : à lancer par une tâche planifiée (cron), par exemple chaque mois.
set -eu
cd /srv/tiles
docker run --rm -e JAVA_TOOL_OPTIONS="-Xmx8g" -v "$(pwd)/data":/data \
  ghcr.io/onthegomap/planetiler:latest \
  --download --area=france --output=/data/france-next.pmtiles --force
# Bascule : le nouveau fichier remplace l'ancien d'un coup, puis Martin le recharge.
mv data/france-next.pmtiles data/france.pmtiles
docker compose restart martin

Variante sans serveur de tuiles : PMTiles sur un stockage objet

Le format PMTiles a été pensé pour être lu directement par le navigateur, par requêtes HTTP partielles (en-tête Range). Déposé sur un stockage objet compatible S3, le fichier france.pmtiles n’a plus besoin de Martin :

Terminal
npm install pmtiles
map.js · variante PMTiles
import maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";

const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);

// Dans le style : la source pointe directement sur le fichier.
// "url": "pmtiles://https://cartes.example.org/france.pmtiles"

Martin garde l’avantage quand vous voulez combiner plusieurs sources, servir des données métier depuis PostGIS à côté du fond de carte, ou garder la main sur les en-têtes et les accès.

Questions fréquentes

Faut-il une clé d'API pour afficher une carte OpenStreetMap ?

Non. Avec des tuiles vectorielles hébergées sur vos serveurs, aucun compte ni clé n’est nécessaire chez un fournisseur de cartes. Les données OpenStreetMap sont libres, sous licence ODbL : seule l’attribution « © Contributeurs OpenStreetMap » est obligatoire.

Quelle différence entre tuiles raster et tuiles vectorielles ?

Une tuile raster est une image dessinée par le serveur. Une tuile vectorielle transporte des géométries et des attributs que le navigateur dessine selon un style : un seul jeu de tuiles sert alors plusieurs apparences, nettes à tous les zooms.

Peut-on se passer d'un serveur de tuiles ?

Oui : un fichier PMTiles déposé sur un stockage objet compatible S3 est lu directement par MapLibre, par requêtes HTTP partielles. Le stockage doit accepter les requêtes Range et le CORS. Un serveur comme Martin reste utile pour combiner plusieurs sources ou servir des données PostGIS.

Quelle machine pour générer les tuiles d'un pays comme la France ?

La génération demande surtout de la mémoire et un disque rapide. Une instance à mémoire renforcée, par exemple 8 vCPU et 64 Go de RAM, convient pour une région ou un pays ; la planète entière demande un serveur dédié bien plus large.

À quelle fréquence mettre à jour les tuiles ?

Selon vos besoins : une régénération mensuelle, lancée par une tâche planifiée, suffit à la plupart des applications métier. La carte reste disponible pendant toute la génération : le nouveau fichier ne remplace l’ancien qu’une fois terminé.

En résumé

BriqueRôleAlternative
PlanetilerGénère les tuiles depuis OpenStreetMapTilemaker, OpenMapTiles (outils d’origine)
PMTilesUn fichier unique, facile à déplacerMBTiles (SQLite)
MartinSert tuiles, glyphes et stylesTileserver GL, ou PMTiles en direct
TraefikHTTPS, certificats, en-têtesNGINX, Caddy
MapLibre GL JSDessine la carte dans le navigateurOpenLayers

Une carte auto-hébergée, c’est quelques conteneurs, un fichier de données et un style. En échange, vos cartes ne dépendent plus d’un tiers, ni pour leur prix, ni pour leur disponibilité, ni pour la confidentialité de ce que vos utilisateurs consultent.