Proxy inverse Traefik : 5 apps, un Compose
Faites tourner cinq apps derrière une IP avec Traefik v3 sur Docker Compose : routage par règle Host, TLS Let's Encrypt automatique et le piège acme.json.
Une seule IP, cinq apps, un seul port 443
Votre VPS possède une seule adresse IPv4 publique et un seul port TCP 443. Vous voulez y faire tourner Gitea, une copie de préproduction de votre app, un tableau de bord interne, une page de statut et un récepteur de webhooks : cinq noms d'hôte, une seule machine. Un proxy inverse est le processus qui possède le :80 et le :443, lit l'en-tête Host de chaque requête et la transmet au bon conteneur. Traefik fait cela, et il obtient puis renouvelle un certificat pour chaque nom d'hôte sans que vous ayez jamais à lancer certbot à la main.
Ce qui sépare Traefik d'un bloc server {} de nginx, c'est l'origine de sa configuration. Avec nginx vous éditez un fichier puis rechargez, et le cycle de vie du certificat reste une corvée séparée : le déroulé que vous suivez quand vous émettez des certificats Let's Encrypt avec certbot sur nginx, où un minuteur de renouvellement vit entièrement en dehors du serveur web. Le fournisseur Docker de Traefik surveille le flux d'événements Docker et lit des labels sur vos conteneurs : démarrez un conteneur portant un label de règle Host() et il est routable en une seconde ; arrêtez-le et la route disparaît. C'est aussi le piège. Une configuration qui vit dans des labels vit à cinq endroits à la fois, et un mauvais label est silencieux : le conteneur n'est simplement pas routé, et Traefik ne dit rien.
Les quatre noms
- Les entrypoints (points d'entrée) sont des sockets d'écoute. Vous en définirez deux :
websur:80etwebsecuresur:443. - Les routers (routeurs) font correspondre une requête (
Host(...)) et l'attachent à un service. Les certificats sont demandés par routeur, viatls.certresolver. - Les services sont le backend : un conteneur et le port sur lequel il écoute à l'intérieur du réseau Docker.
- Les middlewares se placent entre le routeur et le service : authentification basique, listes d'autorisation d'IP, réécritures d'en-têtes, redirections.
La configuration statique (points d'entrée, fournisseurs, ACME) est passée sur la ligne de commande de Traefik ou dans traefik.yml, et la modifier impose de redémarrer Traefik. La configuration dynamique (routeurs, services, middlewares) arrive depuis les labels des conteneurs et est rechargée à chaud. Confondre les deux est la source habituelle du « mon option ne fait rien ».
Le fichier compose
Un unique réseau Docker partagé nommé proxy est la colonne vertébrale. Traefik n'atteint un conteneur que si les deux y sont rattachés.
name: edge
networks:
proxy:
name: proxy
services:
traefik:
image: traefik:v3.5
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedByDefault=false
- --providers.docker.network=proxy
- --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.email=you@example.com
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.tlschallenge=true
# while you iterate, point at staging so a mistake costs nothing:
# - --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
- --api.dashboard=true
- --log.level=INFO
- --accesslog=true
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
networks:
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)
- traefik.http.routers.dashboard.entrypoints=websecure
- traefik.http.routers.dashboard.tls.certresolver=le
- traefik.http.routers.dashboard.service=api@internal
- traefik.http.routers.dashboard.middlewares=dashboard-auth
- traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$apr1$$REPLACE$$THIS
gitea:
image: gitea/gitea:1 # major-only pin keeps this demo copy-pasteable; pin an exact release in production
restart: unless-stopped
volumes:
- ./gitea:/data
networks:
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.gitea.rule=Host(`git.example.com`)
- traefik.http.routers.gitea.entrypoints=websecure
- traefik.http.routers.gitea.tls.certresolver=le
- traefik.http.services.gitea.loadbalancer.server.port=3000docker compose up -d, puis docker compose logs -f traefik. Chaque app supplémentaire est une copie du bloc gitea avec son propre nom de routeur, son propre Host() et son propre port interne. Une installation Nextcloud tournant dans Docker avec TLS et sauvegardes s'insère de la même manière : retirez ses ports publiés, rattachez-la à proxy, et laissez les labels de routeur gérer le nom d'hôte et le certificat.
Cinq détails ici justifient leur présence.
exposedByDefault=false rend un conteneur invisible pour Traefik tant qu'il ne porte pas traefik.enable=true. Omettez-le et chaque conteneur que vous démarrez un jour, y compris le postgres jetable lancé pour vérifier quelque chose, se voit générer une route.
providers.docker.network=proxy indique à Traefik quel réseau utiliser quand un conteneur est rattaché à plusieurs. Omettez-le et Traefik peut choisir la mauvaise IP de conteneur, ce qui se manifeste par un 502 qui ressemble à une panne applicative.
loadbalancer.server.port=3000 est le port à l'intérieur du conteneur ; Gitea y écoute sur 3000. Remarquez qu'aucun conteneur d'app ne publie de port du tout : seul Traefik le fait.
La redirection sur le point d'entrée web transforme les requêtes en clair en un 308 vers HTTPS. Le port 80 reste ouvert de toute façon : le challenge ACME HTTP en a besoin, tout comme les humains qui tapent un nom d'hôte nu.
Le double $$ dans le hash de l'authentification basique est un échappement Compose, pas une faute de frappe. Générez-le avec htpasswd -nbB admin 'your-password' (paquet apache2-utils), puis doublez chaque $.
Le certificat, et le piège d'acme.json
tlschallenge=true sélectionne TLS-ALPN-01 : Let's Encrypt se connecte à votre machine sur le 443 et Traefik répond au challenge à l'intérieur de la poignée de main TLS. L'alternative est HTTP-01, sur le port 80 : remplacez la ligne tlschallenge dans la liste command: de Traefik par ces deux-ci :
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=webLes deux fonctionnent. Toutes deux exigent que le DNS public du nom d'hôte pointe déjà vers votre VPS : l'autorité de certification résout le nom et se connecte depuis l'extérieur. Créez d'abord l'enregistrement A (et AAAA), confirmez avec dig +short git.example.com, puis démarrez Traefik.
Voici maintenant le piège qui coûte une soirée aux gens. Traefik conserve sa clé de compte ACME et chaque certificat émis dans un seul acme.json. Si ce fichier est lisible par le groupe ou par tout le monde, Traefik affiche une ligne très proche de celle-ci et s'arrête :
error: unable to get ACME account: permissions 644 for /letsencrypt/acme.json are too open, please use 600Le correctif propre est celui ci-dessus : montez le répertoire par bind-mount et laissez Traefik créer le fichier lui-même avec le bon mode. Si vous avez créé acme.json avec touch, votre umask l'a mis en 644. Réparez-le sur l'hôte :
chmod 600 ./letsencrypt/acme.json
docker compose restart traefikSauvegardez ce répertoire avec vos volumes d'app. Le perdre est survivable, les certificats se réémettent, mais réémettre cinq noms d'hôte d'un coup vous conduit droit dans les limites de débit.
Utilisez l'autorité de certification de préproduction (staging) pendant que vous itérez. Décommentez la ligne caserver, faites fonctionner chaque route, puis recommentez-la et supprimez acme.json pour que les certificats de production soient demandés à neuf. Le Let's Encrypt de production autorise cinq certificats dupliqués par semaine pour un ensemble identique de noms d'hôte, et limite les validations en échec répétées pour un même nom. Le staging émet des certificats non fiables : votre navigateur avertit, et cet avertissement est le signe que cela a fonctionné, avec des limites bien plus souples.
Le tableau de bord est une surface de contrôle, pas une démo
La plupart des démarrages rapides mettent --api.insecure=true, qui sert le tableau de bord sur le port 8080 sans aucune authentification. Sur une machine à IP publique, cela livre votre topologie de routage, vos noms d'hôte, les noms de vos middlewares et les ports de vos backends à quiconque la scanne.
Les labels sur le service traefik ci-dessus sont l'alternative : le tableau de bord routé comme n'importe quelle autre app, sur un vrai nom d'hôte, en TLS, derrière basicauth. service=api@internal est ce qui câble le routeur à l'API interne de Traefik. Renforcez-le encore en enchaînant une liste d'autorisation d'IP, appliquée de gauche à droite. Si l'adresse de votre bureau est dynamique, réglez la plage sur le sous-réseau distribué par un VPN WireGuard que vous auto-hébergez sur le même VPS et n'atteignez le tableau de bord que par le tunnel :
- traefik.http.middlewares.office.ipallowlist.sourcerange=10.0.0.7/32
- traefik.http.routers.dashboard.middlewares=office,dashboard-authLe socket Docker, c'est root
/var/run/docker.sock est une API capable de créer un conteneur qui monte le / de l'hôte. Y accéder équivaut à être root sur la machine, et Traefik en a besoin pour lire les labels.
Gardez le :ro sur le montage, mais soyez clair sur ce qu'il apporte : il rend le fichier socket en lecture seule. Il n'empêche pas les requêtes POST vers l'API Docker de transiter par lui. La vraie parade est de ne jamais confier le socket à Traefik, et d'interposer un proxy filtrant :
dockerproxy:
image: tecnativa/docker-socket-proxy # pin the current tag
restart: unless-stopped
environment:
CONTAINERS: 1
NETWORKS: 1
POST: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxyRetirez le volume du socket de Traefik et pointez le fournisseur vers le proxy :
--providers.docker.endpoint=tcp://dockerproxy:2375Traefik conserve l'accès en lecture aux conteneurs et aux réseaux, et perd la capacité de créer quoi que ce soit.
Pare-feu, ports, et la règle que tout le monde rate
Deux ports ouverts, plus SSH :
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableLes ports publiés par Docker contournent ufw. Docker insère ses propres règles iptables, évaluées avant les chaînes d'ufw, si bien qu'un conteneur démarré avec ports: ["3000:3000"] est joignable depuis Internet alors qu'un ufw deny est juste là. La défense est structurelle, pas une affaire de configuration de pare-feu : ne publiez de ports que depuis Traefik, et donnez à chaque autre conteneur networks: [proxy] et rien d'autre. Si quelque chose doit vraiment atteindre l'hôte, liez-le à loopback : "127.0.0.1:3000:3000".
Dépannage : les erreurs que vous verrez vraiment
404 page not found, servi par Traefik. Aucun routeur n'a correspondu. Par ordre de probabilité : le conteneur n'a pas traefik.enable=true (avec exposedByDefault=false réglé) ; la règle Host() ne correspond pas au nom que vous avez tapé ; le nom de routeur d'un label diffère du nom de routeur d'un autre (routers.gitea.rule et routers.gitea.entrypoints doivent être le même mot) ; ou vous avez entouré le nom d'hôte de guillemets au lieu de backticks. Traefik v3 exige des backticks à l'intérieur des matchers.
502 Bad Gateway. Un routeur a correspondu mais le backend était injoignable. Presque toujours, le conteneur n'est pas sur le réseau proxy : vérifiez avec docker inspect -f '{{json .NetworkSettings.Networks}}' gitea. L'autre candidat est un mauvais loadbalancer.server.port : vous lui avez donné un port publié, ou l'app écoute ailleurs. Le journal nomme la tentative : dial tcp 172.18.0.5:8080: connect: connection refused.
Le navigateur avertit, et le certificat est émis pour TRAEFIK DEFAULT CERT. Aucun certificat n'existe pour ce nom d'hôte et Traefik a servi son certificat placeholder auto-signé. Lisez les lignes ACME :
unable to obtain ACME certificate for domains "git.example.com" ...
acme: error: 400 ... DNS problem: NXDOMAIN looking up A for git.example.comLe DNS ne pointe pas encore vers la machine. Corrigez l'enregistrement, attendez la fin du TTL, redémarrez Traefik.
Invalid response from http://git.example.com/.well-known/acme-challenge/... sur le challenge HTTP : le port 80 n'atteint pas Traefik depuis l'extérieur, généralement un pare-feu au niveau de l'hébergeur devant le VPS, pas ufw.
Les certificats ne s'émettent jamais, et votre DNS est sur Cloudflare avec le nuage orange activé. Cloudflare termine le TLS à sa périphérie et TLS-ALPN-01 ne peut pas aboutir à travers lui. Réglez l'enregistrement sur DNS-only pendant l'émission, ou passez au challenge DNS-01 avec un jeton d'API. DNS-01 est aussi le seul challenge qui émet des wildcards.
Boucle de redirection. Quelque chose devant Traefik termine déjà le TLS et transmet en clair vers le :80 ; la redirection du point d'entrée le renvoie vers HTTPS. Retirez l'une des deux redirections.
Le garder en fonctionnement
L'unité de Docker doit être activée au démarrage (systemctl is-enabled docker), et restart: unless-stopped ramène la pile après un redémarrage. Pour une prise en main explicite, une petite unité systemd lançant docker compose -f /srv/edge/compose.yml up -d avec RemainAfterExit=yes vous donne systemctl status edge et le contrôle de l'ordonnancement.
Épinglez le tag de Traefik (traefik:v3.5, jamais latest). La mise à niveau v2 vers v3 a changé la syntaxe des règles et les noms des fournisseurs, et un latest sans surveillance rechargera allègrement une configuration qu'il ne comprend plus. Mettez à niveau délibérément : lisez les notes de migration, montez le tag, docker compose up -d traefik, surveillez le journal. Si vous êtes encore sur un tag v2, le guide de migration de Traefik v2 vers v3 parcourt chaque renommage, le mode de compatibilité, et un retour arrière qui préserve vos certificats.
Sauvegardez ./letsencrypt et le volume de données de chaque app. Traefik ne détient aucun autre état que vous ne puissiez reconstruire depuis le fichier compose.
Ce qui casse à l'échelle
Le premier plafond n'est pas le débit, c'est la machine unique : un seul Traefik sur un seul VPS est un point de défaillance unique pour cinq apps, et acme.json est un stockage en fichier plat : deux instances de Traefik qui l'écrivent le corrompront. Passer à l'échelle horizontale impose de sortir le stockage des certificats d'un fichier, ou de terminer le TLS ailleurs.
Le deuxième, ce sont les connexions de longue durée. Les server-sent events, les gros téléversements et les clients lents se heurtent aux délais de réponse du point d'entrée ; --entryPoints.websecure.transport.respondingTimeouts.readTimeout et ses frères writeTimeout et idleTimeout sont les molettes. Les WebSockets passent sans configuration supplémentaire.
Le troisième, c'est le disque. --accesslog=true écrit sur stdout, et le pilote json-file de Docker conserve cela indéfiniment sauf si vous le plafonnez. Réglez logging.options.max-size sur le service Traefik, ou écrivez le journal d'accès dans un fichier et faites-en la rotation.
Rien de tout cela n'exige un orchestrateur. Cela exige un serveur que vous contrôlez, avec une vraie IP et les ports 80 et 443 ouverts au monde : un unique petit VPS est toute la liste des dépendances.
FAQ
Ai-je encore besoin de certbot si je fais tourner Traefik ?
Non. Le résolveur ACME de Traefik demande et renouvelle le certificat de chaque nom d'hôte qu'il route, et stocke le tout dans acme.json. Certbot reste le bon outil quand nginx ou un autre serveur termine le TLS lui-même ; faire tourner les deux contre les mêmes noms d'hôte ne fait que brûler les limites de débit de Let's Encrypt.
Pourquoi mon conteneur renvoie-t-il un 404 à travers Traefik ?
Un 404 servi par Traefik signifie qu'aucun routeur n'a correspondu à la requête. Vérifiez que le conteneur porte traefik.enable=true (obligatoire dès que exposedByDefault=false est réglé), que la valeur Host() correspond au nom que vous avez tapé, et que le nom de routeur est identique sur chaque label de cette app. Traefik v3 veut aussi des backticks à l'intérieur du matcher, pas des guillemets.
Quelle est la différence entre un 404 et un 502 ici ?
Un 404 signifie que le routage n'a jamais eu lieu ; un 502 signifie qu'un routeur a correspondu et que le backend a refusé la connexion. Les candidats habituels au 502 sont un conteneur non rattaché au réseau proxy, et un loadbalancer.server.port pointant vers un port publié plutôt que vers le port sur lequel l'app écoute à l'intérieur du conteneur. Le journal d'accès nomme l'adresse exacte que Traefik a composée.
Monter le socket Docker en lecture seule suffit-il ?
Le drapeau :ro rend le fichier socket en lecture seule, pas l'API derrière lui : les requêtes POST transitent toujours par lui, et l'accès à l'API Docker équivaut à root sur l'hôte. L'arrangement plus solide est le conteneur docker-socket-proxy montré ci-dessus, qui n'expose à Traefik que les lectures de conteneurs et de réseaux et bloque purement les écritures.
Traefik peut-il émettre un certificat wildcard ?
Uniquement via le challenge DNS-01, avec un jeton d'API pour votre fournisseur DNS. TLS-ALPN-01 et HTTP-01 valident chacun un seul nom d'hôte et ne peuvent pas produire de wildcard. DNS-01 est aussi la réponse quand un CDN comme Cloudflare termine le TLS devant votre VPS et que les deux autres challenges n'aboutissent jamais.