Docker contourne ufw : le piège des ports publiés
Un port publié par Docker reste joignable depuis Internet même si ufw affiche « deny ». Le mécanisme, le test depuis l'extérieur et le correctif sur Debian 13.
Sur mon VPS Debian 13, ufw est en deny incoming et n’autorise que trois ports : 22, 80 et 443, en IPv4 comme en IPv6. sudo ufw status le confirme, tout a l’air propre. Et pourtant un conteneur lancé avec -p 5432:5432 répondait depuis Internet, pendant qu’ufw affichait sagement que 5432 n’était pas autorisé.
Ce n’est pas un bug, c’est le fonctionnement documenté de Docker. La doc officielle le dit désormais sans détour : Docker et ufw sont incompatibles. Le problème, c’est qu’on ne le découvre en général qu’une fois la base de données exposée.
À la fin de cet article, vous saurez pourquoi ufw ne voit pas ces paquets, comment le vérifier depuis une autre machine (le seul test qui compte), quel correctif j’ai posé dans /etc/docker/daemon.json, la limite de ce correctif que la doc mentionne à peine, et ce que valent les alternatives : DOCKER-USER, ufw-docker, "iptables": false, et le choix de ne rien publier du tout.
Pourquoi ufw ne voit pas les ports publiés par Docker
ufw filtre ce qui est destiné à la machine elle-même : ses règles vivent dans la chaîne INPUT de la table filter. Un port publié par Docker ne passe jamais par là.
Voici le trajet d’un paquet qui arrive sur 203.0.113.10:5432 quand un conteneur publie -p 5432:5432 :
nat/PREROUTING: Docker y a posé une règle DNAT. L’adresse de destination est réécrite en celle du conteneur, par exemple172.18.0.2:5432.- Décision de routage : la destination n’est plus l’hôte mais une adresse d’un pont Docker. Le paquet est donc routé, pas livré localement.
filter/FORWARD: Docker insère en tête de cette chaîne ses sauts versDOCKER-USER, puisDOCKER-FORWARDet les chaînes qui suivent. Elles acceptent le paquet puisque le port est publié.
La chaîne INPUT, celle d’ufw, n’est jamais traversée. Et les chaînes ufw-*-forward d’ufw, ajoutées dans FORWARD après celles de Docker, arrivent trop tard : le paquet est déjà accepté.
On le voit directement dans les règles. Sur Debian 13, la commande iptables passe par nftables (iptables-nft), mais l’affichage reste le même :
# La traduction d'adresse posée par Docker pour chaque port publié
sudo iptables -t nat -S DOCKER
# L'ordre des sauts dans FORWARD : ceux de Docker d'abord, ceux d'ufw ensuite
sudo iptables -S FORWARD
Dans la première sortie, chaque port publié a sa règle -j DNAT --to-destination. Dans la seconde, les -j DOCKER-USER et -j DOCKER-FORWARD précèdent les -j ufw-before-forward.
Vérifier depuis l’extérieur, pas depuis le serveur
Le piège ne se voit pas avec ufw status, et mal depuis le serveur lui-même : une connexion locale ne suit pas le même chemin qu’un paquet venu d’Internet. Le seul test qui prouve quelque chose se fait depuis une autre machine.
Pour reproduire sans exposer de vraie base, un nginx publié sur 5432 suffit :
# Sur le VPS : un conteneur jetable qui publie le port 5432
docker run -d --rm --name test-5432 -p 5432:80 nginx:alpine
Puis, depuis votre poste :
# -Pn : ne pas tenter de ping, scanner directement
nmap -Pn -p 22,80,443,5432 203.0.113.10
# Même chose avec netcat (paquet netcat-openbsd sur Debian)
nc -zv -w 3 203.0.113.10 5432
Avec ufw en deny incoming, un port non autorisé apparaît filtered dans nmap : ufw jette le paquet sans répondre. Un port publié par Docker apparaît open, alors qu’il n’est pas dans la liste d’ufw. Si l’hôte a une adresse IPv6, refaites le test en IPv6 (nmap -6 -Pn -p 5432 2001:db8::10) : sans adresse précisée, Docker publie sur 0.0.0.0 et sur [::].
Côté serveur, deux commandes complètent le tableau :
# Ce que Docker a publié, et sur quelle adresse
docker ps --format 'table {{.Names}}\t{{.Ports}}'
# Ce qui écoute réellement sur l'hôte
sudo ss -tlnH
docker ps affiche 0.0.0.0:5432->80/tcp pour un port ouvert à tous, 127.0.0.1:5432->80/tcp pour un port restreint à l’hôte. Attention, il affiche aussi 5432/tcp tout court pour une image Postgres qui n’a rien publié : c’est un EXPOSE déclaratif, pas une publication. ss -tlnH montre le processus docker-proxy qui écoute pour chaque port publié (tant que le userland-proxy n’est pas désactivé), et c’est lui qui fait foi.
Pensez à supprimer le conteneur de test : docker stop test-5432.
Le correctif retenu : lier les ports publiés à 127.0.0.1 par défaut
Plutôt que de réconcilier Docker et ufw, j’ai changé l’adresse par défaut des ports publiés. Mon /etc/docker/daemon.json :
{
"ip": "127.0.0.1",
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
La clé ip fixe l’adresse de l’hôte utilisée quand une publication n’en précise pas. Un -p 5432:5432 devient 127.0.0.1:5432:5432 : joignable depuis l’hôte (pratique pour un psql de dépannage), invisible depuis Internet. Les deux autres clés n’ont rien à voir avec le pare-feu : elles plafonnent les journaux des conteneurs à 3 fichiers de 10 Mo.
On valide le fichier avant de redémarrer le démon, sinon Docker ne repart pas :
sudo dockerd --validate --config-file /etc/docker/daemon.json
sudo systemctl restart docker
Le redémarrage arrête tous les conteneurs ; ceux en restart: unless-stopped repartent seuls.
Conséquence directe : le seul service qui doit être public, le reverse proxy, doit le demander explicitement. Extrait du docker-compose.yml de Caddy :
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
# 0.0.0.0 explicite : les ports publiés se lient à 127.0.0.1 par défaut,
# et Caddy est justement le seul service qui doit être public.
ports:
- "0.0.0.0:80:80"
- "0.0.0.0:443:443"
- "0.0.0.0:443:443/udp" # HTTP/3
- "[::]:80:80" # IPv6
- "[::]:443:443"
- "[::]:443:443/udp"
networks:
- web
networks:
web:
external: true
Piège vécu : 0.0.0.0 ne couvre que l’IPv4. Mon domaine avait un enregistrement AAAA, mais Caddy ne répondait à aucune connexion IPv6. Les navigateurs se rabattent discrètement sur l’IPv4, si bien que je ne l’ai découvert qu’en enquêtant sur un sitemap que la Search Console n’arrivait pas à lire. Si votre serveur a une adresse IPv6 publiée dans le DNS, publiez aussi [::].
Après ce changement, un conteneur de test publié sur 5432 est resté injoignable depuis Internet. Le principe me plaît : c’est l’ouverture qui demande un geste explicite, pas la fermeture. Un ports: oublié dans un compose ne se traduit plus par une porte ouverte. (Si votre site passe par Cloudflare, j’ai détaillé comment fermer aussi l’accès direct à Caddy.)
La limite du correctif : ip ne vaut que pour le réseau par défaut
C’est le point que la plupart des tutoriels ratent, et la doc sur la publication de ports le précise en une ligne : la clé ip change l’adresse par défaut du réseau bridge par défaut, celui qu’utilise un docker run sans --network. Le code de Docker le confirme : l’option n’est appliquée qu’à la création de ce réseau-là (initBridgeDriver).
Or Docker Compose crée ses propres réseaux (monprojet_default, interne…), et les réseaux externes comme web sont eux aussi des réseaux « définis par l’utilisateur ». Pour eux, la clé ip ne s’applique pas : un ports: ["5432:5432"] dans un compose se lie toujours à toutes les adresses.
Pour couvrir aussi ces réseaux, il faut l’option default-network-opts, disponible depuis Docker 24 :
{
"ip": "127.0.0.1",
"default-network-opts": {
"bridge": {
"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
}
},
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Deux précisions :
- L’option s’applique à la création d’un réseau. Les réseaux existants gardent leurs options : il faut les recréer (
docker compose downpuisup -dpour ceux d’un projet,docker network rm webpuisdocker network create webpour un réseau externe, une fois ses conteneurs arrêtés). - Pour vérifier qu’un réseau en a hérité :
docker network inspect web --format '{{json .Options}}'
La sortie doit contenir "com.docker.network.bridge.host_binding_ipv4":"127.0.0.1".
Sur mon VPS, deux garde-fous couvrent cette limite, et ils valent mieux que n’importe quelle option de démon. D’abord, la règle des projets : aucun ports: sauf sur Caddy, et jamais de port de base de données, même sur localhost (section suivante). Ensuite, un contrôle hebdomadaire lancé par un timer systemd (voir pourquoi j’ai remplacé cron par des timers) cherche tout port qui écoute ailleurs que sur la boucle locale :
# Ports en écoute hors 127.x et [::1], hors 22/80/443
ss -tlnH | awk '$4 !~ /^(127\.|\[::1\])/ {print $4}' | grep -vE ':(22|80|443)$'
Et un audit lit l’adresse réelle de chaque port publié par les conteneurs en service :
docker ps -q | xargs -r docker inspect \
--format '{{.Name}} {{range $p, $l := .NetworkSettings.Ports}}{{range $l}}{{$p}}={{.HostIp}} {{end}}{{end}}'
Tout port autre que 80/443 publié sur 0.0.0.0 ou :: y est signalé. Le correctif du démon est un filet ; ces contrôles vérifient que le filet tient.
Ce qui a changé avec Docker 28 et 29
La page « Packet filtering and firewalls » a pas mal bougé récemment. Ce qui compte ici :
- Docker 28.0 a fermé un trou dans le correctif lui-même. Avant la 28.0.0, des machines sur le même segment réseau de niveau 2 (le même switch) pouvaient joindre un port publié sur
127.0.0.1(moby#45610). Chez un hébergeur, les voisins de segment sont d’autres clients. Mon VPS tourne en Docker 29.8.1 : non concerné, mais sur une version plus ancienne, lier à localhost ne suffisait pas. - Docker 28.0.1 a réorganisé les chaînes : la plupart des règles de Docker ont quitté
FORWARDpour une chaîneDOCKER-FORWARD, etDOCKER-USERreste appelée avant elle (notes de version). Les règlesDOCKER-USERexistantes continuent de fonctionner. - Docker 29 introduit un backend nftables, activé par
"firewall-backend": "nftables". Il est expérimental, incompatible avec le mode Swarm, et n’active plus lui-même le routage IP. Surtout : il n’y a plus de chaîneDOCKER-USER. Docker crée ses tablesip docker-bridgesetip6 docker-bridges, et vos propres règles doivent vivre dans une table à vous, avec une priorité choisie par rapport à celles de Docker. Tout ce qui s’appuie surDOCKER-USER(règles maison, ufw-docker) est alors ignoré, sans erreur.
Le backend par défaut reste iptables. Le correctif par adresse de liaison, lui, ne dépend pas du backend : il fonctionne à l’identique avec les deux.
Les alternatives, et pourquoi je ne les ai pas retenues seules
Ne rien publier : passer par un réseau Docker partagé
C’est la vraie solution, et elle est complémentaire du correctif. Dans mon modèle de projet, l’application ne publie aucun port. Caddy la joint de l’intérieur, par un réseau Docker partagé :
services:
api:
# PAS de `ports:`. Caddy joint ce conteneur par le réseau `web`.
networks:
web:
aliases: [monprojet-api] # alias unique, visé par Caddy
interne: {}
db:
image: postgres:18-alpine
networks: [interne] # ni Internet, ni les autres projets
networks:
web:
external: true
interne:
internal: true
Dans le Caddyfile, reverse_proxy monprojet-api:8000 suffit. Un port non publié ne crée aucune règle DNAT : il n’y a rien à contourner. Le correctif du démon ne sert qu’à rattraper le jour où un ports: réapparaît par erreur.
La chaîne DOCKER-USER
Docker réserve DOCKER-USER aux règles de l’administrateur, évaluées avant les siennes. On peut y reproduire une politique « seuls 80 et 443 entrent » :
# eth0 = l'interface publique (voir `ip route show default`)
sudo iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
sudo iptables -I DOCKER-USER 2 -i eth0 -p tcp -m conntrack --ctorigdstport 80 -j RETURN
sudo iptables -I DOCKER-USER 3 -i eth0 -p tcp -m conntrack --ctorigdstport 443 -j RETURN
sudo iptables -I DOCKER-USER 4 -i eth0 -p udp -m conntrack --ctorigdstport 443 -j RETURN
sudo iptables -I DOCKER-USER 5 -i eth0 -j DROP
Le conntrack --ctorigdstport est indispensable : quand le paquet atteint DOCKER-USER, le DNAT a déjà eu lieu, et --dport verrait le port du conteneur, pas celui demandé (doc iptables de Docker). La première règle laisse passer les réponses aux connexions sortantes des conteneurs.
Pourquoi je ne l’ai pas retenu : ces règles ne survivent pas à un redémarrage sans outillage de persistance, il faut les dupliquer avec ip6tables, elles forment une deuxième liste de ports autorisés à tenir en phase avec ufw, et elles disparaissent en silence le jour où l’on passe au backend nftables.
ufw-docker
ufw-docker automatise l’approche précédente : ufw-docker install ajoute dans /etc/ufw/after.rules un bloc qui branche DOCKER-USER sur les règles route d’ufw. On ouvre ensuite un service avec ufw route allow proto tcp from any to any port 80.
C’est la meilleure option pour qui veut piloter tout le pare-feu depuis ufw. Deux réserves : les règles visent le port du conteneur, pas celui de l’hôte (pour -p 8080:80, on autorise 80), ce qui piège facilement ; et l’outil repose entièrement sur DOCKER-USER, donc ne fonctionne pas avec le backend nftables de Docker 29. J’ai préféré ne rien ajouter : moins de pièces, moins de dérive.
"iptables": false : la mauvaise bonne idée
Beaucoup de fils de discussion anciens conseillent de mettre "iptables": false dans daemon.json. Docker ne touche plus au pare-feu, ufw reprend la main. Sur le papier.
En pratique, la doc est claire : cette option « va probablement casser le réseau des conteneurs ». Sans règles de masquerading, les conteneurs des réseaux bridge perdent l’accès à Internet, et sans règles de filtrage, tous leurs ports deviennent accessibles aux machines du réseau local. On échange une fuite contre une panne plus une autre fuite. L’option s’applique aussi au backend nftables. À éviter.
Comparatif
| Approche | Effort | Survit au reboot | Backend nftables | Ce qu’elle protège |
|---|---|---|---|---|
| Ne rien publier + réseau partagé | faible | oui | oui | tout ce qui n’est pas la façade |
ip + default-network-opts |
une fois | oui | oui | tout ports: sans adresse |
DOCKER-USER à la main |
moyen | non (sans outil) | non | selon les règles |
| ufw-docker | moyen | oui | non | selon les règles ufw |
"iptables": false |
faible | oui | - | rien, et casse le réseau |
À retenir
- Un port publié par Docker passe par
natpuisFORWARD, jamais parINPUT: ufw ne le voit pas, quoi que diseufw status. - Le seul test fiable se fait depuis une autre machine :
nmap -Pn -p <ports> <ip>, en IPv4 et en IPv6. "ip": "127.0.0.1"dansdaemon.jsonne couvre que le réseaubridgepar défaut. Ajoutezdefault-network-optspour les réseaux Compose, et recréez les réseaux existants.- Le reverse proxy publie explicitement ses ports sur
0.0.0.0et[::](sinon pas d’IPv6) ; tout le reste passe par un réseau Docker, sansports:. DOCKER-USERet ufw-docker fonctionnent, mais pas avec le backend nftables de Docker 29."iptables": falsecasse le réseau.- Un contrôle périodique (
ss -tlnH,docker inspect) vérifie que la règle tient dans la durée.