Gérez vos sites de production avec Traefik, Docker & Let's Encrypt

Passez votre prod sous Docker avec Traefik (v2) et docker-compose en moins de 30mn chrono.

Gérez vos sites de production avec Traefik, Docker & Let's Encrypt

Vous avez une vieille app legacy en PHP 5 sur votre serveur de production qui vous empêche de déployer votre belle app Symfony 5 ? Vous montez un nouveau serveur de production et vous vous demandez comment gérer facilement des stacks différentes et l'ajout de nouveaux sites ? Vous voulez faire la fête comme cet ananas sur la cover ? Docker est peut-être la solution. En le combinant avec Traefik, vous avez une solution simple et rapide pour gérer vos apps de production et même le renouvellement automatique de vos certificats SSL avec Let's Encrypt.

Pré-requis : Je pars du principe que vous avez un minimum de notions avec Docker et docker-compose (au moins des notions de Dockerfile et de routage de ports entre le conteneur et le host). Je pars aussi du principe que vous êtes quelqu'un de bien et que votre serveur de production est sous Linux.

Traefik, c'est quoi ?

Schéma d'architecture Traefik
Crédits : https://traefik.io/

En fait, Traefik va jouer le rôle de reverse proxy. C'est à dire qu'il va être le récepteur frontal de toutes les requêtes HTTP qui vont venir des Internets et va les rediriger vers les bonnes instances de vos conteneurs Docker. Et c'est bien normal de mettre en place un reverse proxy quand vous avez plus d'une app Docker à exposer : vous ne pouvez pas toutes les faire écouter sur le port 80 ou 443. Vous voulez aussi un joli nom de domaine associé à votre instance Docker.

Installation de Traefik

La première étape est bien sûr d'installer Docker. Une fois chose faite, on va gérer tout le reste avec docker-compose. À l'heure où j'écris ces lignes, la version de docker-compose est la 1.23.2. L'installation est assez simple selon la documentation de Docker :

curl -L "https://github.com/docker/compose/releases/download/1.23.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
docker-compose --version

On va commencer par créer un network qui sera partagé entre tous les conteneurs de nos applications. Je l'ai appelé "web" parce que j'aime être original mais aussi parce que c'est dur de trouver des noms, comme le rappelle ce brave Phil :

There are only two hard things in Computer Science: cache invalidation and naming things.
-- Phil Karlton

docker network create web

On crée ensuite notre fichier docker-compose.yml (j'ai pris pour habitude de mettre la configuration des conteneurs directement dans un dossier /apps à la racine du serveur de production, mais vous pouvez bien sûr changer l'emplacement selon vos habitudes) :

# /apps/traefik/docker-compose.yml
version: '3'

services:
  reverse-proxy:
    image: traefik:v2.4
    container_name: traefik
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - $PWD/traefik.toml:/etc/traefik/traefik.toml
      - $PWD/acme.json:/acme.json
    restart: always
    networks:
      - web
networks:
  web:
    external: true

Les directives vont créer une instance à partir de l'image officielle de Traefik. On veut qu'il réponde à la fois pour HTTP et HTTPS parce qu'en 2021, Coronavirus ou pas, y'a plus d'excuses pour ne pas proposer par défaut des apps sécurisées.
Le conteneur est intégré au sein du network "web" que l'on vient de créer juste avant (c'est pourquoi on le tag comme external car il est géré en dehors de docker-compose).

🤓 « C'est quoi ce port 8080 d'ouvert ? »

Bien vu. Traefik dispose aussi d'une petite interface d'administration pour les services qu'il gère. Nous verrons en fin d'article comment sécuriser ce dashboard.

Pour l'explication des volumes utilisés, il y a 3 raisons.
La première : on utilise Traefik avec Docker. Traefik peut également fonctionner avec d'autres providers (Kubernetes pour les chauds, Consul, une configuration par fichiers, etc), mais dans notre cas on va directement se brancher sur le socket interne de l'engine Docker pour être notifié à chaque création / destruction de conteneurs. Ça va être super pratique pour monter un nouveau site en moins de 5 secondes.
La deuxième : il nous faut modifier la configuration par défaut de Traefik pour qu'elle colle parfaitement à nos besoins. C'est pourquoi j'ai créé un mapping pour le fichier traefik.toml, que je vais détailler juste après.
Dernière raison : Let's Encrypt génère des "challenges" pour les domaines qu'il a réussi à authentifier. C'est pourquoi il nous faut conserver le résultat de ces challenges en dehors de la portée éphémère d'un conteneur Docker.

Configuration de Traefik

Pour gérer HTTPS une fois pour toutes avec Let's Encrypt, on va modifier légèrement la configuration de base de Traefik. Ça se traduit par la création d'un fichier traefik.toml :

# /apps/traefik/traefik.toml
[api]
  dashboard = true
  insecure = true

[entryPoints]
  [entryPoints.web]
    address = ":80"
    [entryPoints.web.http]
      [entryPoints.web.http.redirections]
        [entryPoints.web.http.redirections.entryPoint]
          to = "websecure"
          scheme = "https"
          permanent = true

  [entryPoints.websecure]
    address = ":443"
      [entryPoints.websecure.http.tls]
        certResolver = "default"

[providers]
  [providers.docker]
    watch = true
    exposedByDefault = false
    network = "web"

[certificatesResolvers]
  [certificatesResolvers.default]
    [certificatesResolvers.default.acme]
      email = "[email protected]"
      storage = "acme.json"
      caServer = "https://acme-v01.api.letsencrypt.org/directory"
    [certificatesResolvers.default.acme.tlsChallenge]

Attention, ne recopiez pas cette configuration sans d'abord comprendre ces lignes ! Personnellement, je ne suis pas fan du format TOML mais le contenu reste plutôt simple à lire.
Dans ce setup tout le trafic HTTP est automatiquement redirigé vers HTTPS. Si vous avez encore des apps que vous ne pouvez pas simplement passer en HTTPS (apps legacy, url claquée en dur dans le programme, etc), vous pouvez faire sauter les lignes sous [entryPoints.web.http]. Vous pouvez également commenter temporairement la ligne permanent = true le temps de faire vos tests si vous n'êtes pas trop sûr.e de ce que vous faites !

Traefik va gérer automatiquement pour nous la communication avec les serveurs de Let's encrypt et le renouvellement des certificats. C'est principalement les lignes sous [certificatesResolvers] qui s'occupent de ce job.
La section [providers] indique à Traefik d'aller écouter le socket Docker.
Allez, il nous faut encore créer le fichier acme.json avant de pouvoir admirer le résultat :

touch /apps/traefik/acme.json
chmod 600 /apps/traefik/acme.json

Le plus dur est fait ! Il ne nous reste plus qu'à builder et démarrer notre conteneur :

docker-compose up -d

Si tout s'est bien passé, rendez-vous sur votresupersite.fr:8080. Vous tombez alors sur l'interface d'admin de Traefik. L'infrastructure de base est mise en place. YAY.

L'ajout des apps Docker dans Traefik

Traefik utilise avec les labels de Docker que l'on positionne dans le fichier docker-compose.yml de notre app pour lui indiquer comment gérer le trafic de cette instance.

Du concret

Parce qu'un exemple vaut 1000 lignes d'explications, je vais prendre l'exemple de la configuration de ce blog (qui tourne avec Ghost 4) et qui tient en moins de 25 lignes :

# /apps/blog.silarhi.fr/docker-compose.yml
version: '3'

services:
  app:
    image: ghost:4-alpine
    container_name: blog
    volumes:
        - content:/var/lib/ghost/content
    networks:
        - web
    labels:
        - "traefik.enable=true"
        - "traefik.http.routers.blog.rule=Host(`blog.silarhi.fr`)"
        - "traefik.http.routers.blog.entrypoints=websecure"
    restart: always

volumes:
  content:

networks:
  web:
    external: true

Les informations vraiment importantes ici sont celles des labels (le reste étant standard).
Le premier label traefik.frontend.enable définit que Traefik doit manager ce traefik. Cette ligne est à ajouter si vous avez laissé la configuration exposedByDefault = false dans votre fichier TOML. Dans le cas contraire, vous n'avez pas à vous en occuper.
Le label traefik.http.routers.blog.rule=Host('blog.silarhi.fr') permet de créer un routeur Traefik (que j'ai appelé blog) qui redirige vers CE conteneur TOUT le trafic qu'il reçoit venant des milliards de visiteurs quotidiens de blog.silarhi.fr.
Le dernier label traefik.http.routers.blog.entrypoints=websecure indique que ce conteneur sera disponible sur le port HTTPS (étant donné que Traefik redirige déjà le traffic HTTP vers HTTPS).

🤓 « Et si j'ai une base de données ? »

Facile ! Là encore tout se passe grâce à Docker. Vous pouvez choisir d'utiliser une instance de base de données par app ou bien de mutualiser l'instance pour toutes vos apps. Personnellement, j'ai fais le choix de mutualiser l'instance de MySQL étant donné que j'utilise exclusivement ce SGBD et la même version d'une app à l'autre.

# /apps/database/docker-compose.yml
version: '3'

services:
  mysql:
    image: mysql:8
    command: --default-authentication-plugin=mysql_native_password
    container_name: mysql
    environment:
        MYSQL_ROOT_PASSWORD: supermotdepasse
    volumes:
        - data:/var/lib/mysql
    networks:
        - web
    restart: always

  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    container_name: phpmyadmin
    environment:
        MYSQL_ROOT_PASSWORD: supermotdepasse
        PMA_HOST: mysql
        UPLOAD_LIMIT: 128M
    labels:
        - "traefik.enable=true"
        - "traefik.http.routers.phpmyadmin.rule=Host(`pma.monsupersite.fr`)"
        - "traefik.http.routers.phpmyadmin.entrypoints=websecure"
    restart: always
    volumes:
        - /sessions
    networks:
        - web

volumes:
  data:

networks:
  web:
    external: true

En prime, je vous ai ajouté un conteneur phpMyAdmin (même si c'est vivement déconseillé pour la production). Pour la partie MySQL, on utilise là encore l'image officielle basée sur la version 8.0.
Il ne faut pas ajouter le label traefik.enable=true dans la configuration MySQL ! Le but étant d'exclure ce conteneur de la gestion de Traefik. En effet, on ne veut pas que MySQL soit accessible depuis l'extérieur, on va donc le laisser tranquillement dans son network local "web".

Modifier les informations de connexion à la base de données

La petite modification à faire dans le code de vos apps est de modifier les informations de connexion à la base. Vous devez remplacer en effet 127.0.0.1 par mysql (ou le nom que vous avez donné à votre service dans le fichier docker-compose.yml de MySQL). Eh oui, les conteneurs Docker sont isolés les uns des autres, par conséquence 127.0.0.1 fait référence au localhost du conteneur et non plus à celui du host.

Aller plus loin avec Traefik

Pour celles et ceux qui veulent pousser un peu l'utilisation de Traefik, on va voir ensemble quelques patterns communs que vous pourrez utiliser et reproduire.

Sécuriser le dashboard Traefik

Avec la configuration utilisée plus haut, on a autorisé l'accès publique au dashboard de Traefik sur le port 8080. Voyons un peu comment améliorer tout ça !

On va d'abord commencer par enlever le mode insecure et modifier la configuration :

# /apps/traefik/traefik.toml
[api]
  dashboard = true
- insecure = true
# /apps/traefik/docker-compose.yml
version: '3'
 
 services:
   reverse-proxy:
     image: traefik:v2.4
     container_name: traefik
     ports:
       - "80:80"
       - "443:443"
-      - "8080:8080"
     volumes:
       - /var/run/docker.sock:/var/run/docker.sock
       - $PWD/traefik.toml:/etc/traefik/traefik.toml
       - $PWD/acme.json:/acme.json
     restart: always
+    labels:
+      - "traefik.enable=true"
+      - "traefik.http.routers.api.rule=Host(`monsupersite.fr`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
+      - "traefik.http.routers.api.service=api@internal"
+      - "traefik.http.routers.api.entrypoints=websecure"
     networks:
       - web
 networks:
 networks:

Relancez le conteneur Traefik (docker-compose up -d) et vous devriez pouvoir accéder à https://monsupersite.fr/dashboard/. On progresse, mais le dashboard est toujours accessible publiquement, ce qui n'est pas terrible.

Les middlewares à la rescousse

On va sécuriser l'accès au dashboard en utilisant un middleware. Vous avez déjà du entendre ce nom, c'est un pattern qui revient souvent en programmation. Dans notre cas on va placer une logique qui s'execute entre le routeur du dashboard Traefik (api) et le service associé (api@internal).
Il y a plusieurs manières d'ajouter des middlewares. Pour ma part, je suis partisan de la configuration via les labels de Docker, directement dans le docker-compose.yml de Traefik.

Forcer l'authentification avec auth

# /apps/traefik/docker-compose.yml
version: '3'

services:
  reverse-proxy:
    image: traefik:v2.4
    container_name: traefik
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - $PWD/traefik.toml:/etc/traefik/traefik.toml
      - $PWD/acme.json:/acme.json
    restart: always
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`monsupersite.fr`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
      - "traefik.http.routers.api.service=api@internal"
      - "traefik.http.routers.api.entrypoints=websecure"
+     - "traefik.http.routers.api.middlewares=auth"
+     - "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$6ydic0em$ZAhWtxDpkgZhpBOGT7ibu."
    networks:
      - web
networks:
  web:
    external: true

Avec cette configuration, vous avez protégé l'accès au dashboard avec l'équivalent d'un fichier htpasswd. Veillez à bien doubler les $ dans la chaîne de caractère du mot de passe !

Rediriger www vers non-www avec redirectregex

Vous l'avez compris, on va à nouveau ajouter un middleware dans le docker-compose.yml de Traefik !

# /apps/traefik/docker-compose.yml
version: '3'

services:
  reverse-proxy:
    image: traefik:v2.4
    container_name: traefik
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - $PWD/traefik.toml:/etc/traefik/traefik.toml
      - $PWD/acme.json:/acme.json
    restart: always
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`silarhi.fr`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
      - "traefik.http.routers.api.service=api@internal"
      - "traefik.http.routers.api.entrypoints=websecure"
      - "traefik.http.routers.api.middlewares=auth"
      - "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$6ydic0em$ZAhWtxDpkgZhpBOGT7ibu."
+     - "traefik.http.middlewares.strip-www.redirectregex.regex=^https?://(www\\.)(.+)"
+     - "traefik.http.middlewares.strip-www.redirectregex.replacement=https://$${2}"
+     - "traefik.http.middlewares.strip-www.redirectregex.permanent=true"
    networks:
      - web
networks:
  web:
    external: true

On va maintenant appeler le middleware dans notre site (j'ai repris l'exemple du blog) et ajouter la gestion du www :

# /apps/blog.silarhi.fr/docker-compose.yml
version: '3'

services:
  app:
    image: ghost:4-alpine
    container_name: blog
    volumes:
        - content:/var/lib/ghost/content
    networks:
        - web
    labels:
         - "traefik.enable=true"
-        - "traefik.http.routers.blog.rule=Host(`blog.silarhi.fr`)"
+        - "traefik.http.routers.blog.rule=Host(`blog.silarhi.fr`, `www.blog.silarhi.fr`)"
         - "traefik.http.routers.blog.entrypoints=websecure"
+        - "traefik.http.routers.blog.middlewares=strip-www"
    restart: always

volumes:
  content:

networks:
  web:
    external: true

Gérer des sous-repertoires avec stripprefix

Tout à l'heure, je vous ai montré comment exposer un conteneur phpMyAdmin avec un sous-domaine particulier. On va voir ici comment se passer de la création d'un sous-domaine en le placant plutôt dans un sous repertoire (en plus de le sécuriser avec une authentification !).

# /apps/database/docker-compose.yml
version: '3'

services:
  mysql:
    image: mysql:8
    command: --default-authentication-plugin=mysql_native_password
    container_name: mysql
    environment:
        MYSQL_ROOT_PASSWORD: supermotdepasse
    volumes:
        - data:/var/lib/mysql
    networks:
        - web
    restart: always

  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    container_name: phpmyadmin
    environment:
        MYSQL_ROOT_PASSWORD: supermotdepasse
        PMA_HOST: mysql
+       PMA_ABSOLUTE_URI: "https://monsupersite.fr/_phpmyadmin/"
        UPLOAD_LIMIT: 128M
     labels:
         - "traefik.enable=true"
-        - "traefik.http.routers.phpmyadmin.rule=Host(`pma.monsupersite.fr`)"
+        - "traefik.http.routers.phpmyadmin.rule=Host(`monsupersite.fr`) && PathPrefix(`/_phpmyadmin/`)"
         - "traefik.http.routers.phpmyadmin.entrypoints=websecure"
+        - "traefik.http.routers.phpmyadmin.middlewares=phpmyadmin,auth"
+        - "traefik.http.middlewares.phpmyadmin.stripprefix.prefixes=/_phpmyadmin"
    restart: always
    volumes:
        - /sessions
    networks:
        - web

volumes:
  data:

networks:
  web:
    external: true

Vous êtes maintenant un pro de Docker et Traefik ! N'hésitez pas à me faire part de vos retours / succès / échecs dans les commentaires.

Ressources