Docker contourne ufw : pourquoi les ports publiés restent ouverts, et comment les fermer

Oui, Docker contourne ufw. Quand tu publies le port d'un conteneur, Docker réécrit le trafic dans la table NAT avant qu'il n'atteigne les chaînes où ufw range ses règles : un ufw deny 8080 ne fait donc rien pour un conteneur publié sur 8080. La solution consiste à publier moins de ports, à lier ceux dont tu as besoin à localhost, ou à les filtrer dans la chaîne DOCKER-USER.
Ce piège attrape beaucoup de gens qui font tourner un VPS avec un reverse proxy et une poignée de conteneurs. Le pare-feu a l'air correct, ufw status a l'air correct, et le panneau d'administration de la base de données reste joignable depuis n'importe où.
Pourquoi ufw ne voit pas le trafic des conteneurs
ufw est une surcouche d'iptables. Les règles que tu ajoutes avec ufw allow et ufw deny vivent dans des chaînes rattachées à INPUT, qui traite les paquets adressés à l'hôte lui-même.
Un port de conteneur publié fonctionne autrement. Docker ajoute une règle de NAT de destination qui réécrit un paquet arrivant sur le port 8080 de l'hôte vers l'adresse interne du conteneur. Après cette réécriture, le paquet n'est plus destiné à l'hôte : le noyau le transfère au conteneur par la chaîne FORWARD, et INPUT ne le voit jamais. La documentation de Docker sur le filtrage des paquets décrit le résultat sans détour : le trafic du conteneur est détourné avant que les réglages d'ufw ne s'appliquent, ce qui revient à ignorer la configuration de ton pare-feu.
firewalld a sa propre version du même problème. Quand Docker gère ses règles à côté de firewalld, il crée une zone appelée docker avec la cible ACCEPT, plus une politique de transfert nommée docker-forwarding qui autorise le transfert depuis n'importe quelle zone vers celle-ci.
La page de Docker consacrée à la publication de ports le dit carrément : publier les ports d'un conteneur n'est pas sûr par défaut. Un port publié devient accessible au monde extérieur, pas seulement à l'hôte Docker.
Ce que « ports: 8080:80 » veut vraiment dire
Dans un fichier Compose, cette ligne :
ports:
- "8080:80"
publie le port 80 du conteneur sur le port 8080 de l'hôte, sur toutes les adresses de l'hôte, ce que Docker écrit 0.0.0.0 pour l'IPv4 et [::] pour l'IPv6. Si ton serveur a une adresse IPv6 publique, le conteneur est aussi joignable en IPv6.
Pour lister ce que publie chaque conteneur en cours d'exécution :
docker ps --format '{{.Names}}\t{{.Ports}}'
Toute entrée qui commence par 0.0.0.0: ou [::]: écoute sur toutes les interfaces. Une entrée qui commence par 127.0.0.1: est liée à localhost uniquement. Les conteneurs qui n'ont rien dans la deuxième colonne ne publient aucun port.
Vérifie ce qui est joignable de l'extérieur
ufw status te montre la politique que tu as écrite, pas les ports qu'Internet peut atteindre. Le test honnête, c'est un scan depuis un autre réseau, dirigé vers ton propre serveur et rien d'autre. Un partage de connexion sur ton téléphone ou un petit VPS ailleurs fait l'affaire :
nmap -Pn -p- YOUR_SERVER_IPV4
nmap -6 -Pn -p- YOUR_SERVER_IPV6
-p- scanne les 65 535 ports TCP et -Pn saute la vérification par ping, que les pare-feu bloquent souvent. Compare les ports ouverts avec la liste que tu attends. En général, c'est le 22 pour SSH, plus le 80 et le 443 pour le proxy. Tout le reste est un conteneur qui mérite qu'on s'y attarde : une base de données, un tableau de bord d'administration, un endpoint de métriques, un serveur de développement que quelqu'un a oublié.
Ne scanne que des adresses qui t'appartiennent ou que tu as l'autorisation écrite de tester. Scanner l'infrastructure des autres peut enfreindre leurs conditions d'utilisation et, selon ton pays, la loi.
Correctif 1 : mets le proxy sur un réseau Docker et ne publie rien d'autre
Si ton reverse proxy (Caddy, Traefik, nginx) tourne lui aussi dans Docker, les conteneurs de l'application n'ont besoin d'aucun port publié. Les conteneurs d'un même réseau défini par l'utilisateur se joignent par le nom de leur service :
services:
app:
image: myapp
networks: [web]
db:
image: postgres:17
networks: [web]
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
networks: [web]
networks:
web:
Seul le proxy publie des ports. Dans la configuration du proxy, tu pointes vers app:3000 (ou le port sur lequel ton app écoute à l'intérieur de son conteneur). La base de données est joignable depuis l'app, et depuis rien d'autre en dehors du réseau.
C'est l'organisation la plus propre, parce que rien ne dépend de règles de pare-feu justes.
Correctif 2 : lie les ports publiés à localhost
Si le proxy tourne directement sur l'hôte et non dans un conteneur, lie le port publié à l'adresse de bouclage (loopback) :
ports:
- "127.0.0.1:8080:80"
L'équivalent pour docker run est -p 127.0.0.1:8080:80, et -p '[::1]:8080:80' pour l'IPv6. Le proxy de l'hôte peut joindre le conteneur, le monde extérieur non.
Un avertissement tiré de la documentation de Docker : dans les versions antérieures à 28.0.0, les hôtes situés sur le même segment de couche 2, par exemple des machines branchées sur le même switch, pouvaient quand même joindre des ports publiés sur localhost. Lance docker version sur tes anciens serveurs et mets à jour si nécessaire.
Tu peux aussi changer le comportement par défaut, pour qu'un port publié sans adresse explicite atterrisse sur localhost. Dans /etc/docker/daemon.json, la clé ip fixe l'adresse pour le réseau bridge par défaut, et default-network-opts couvre les réseaux définis par l'utilisateur, ceux que Compose crée pour chaque projet :
{
"ip": "127.0.0.1",
"default-network-opts": {
"bridge": {
"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
}
}
}
Redémarre ensuite le démon Docker et recrée les conteneurs pour que la nouvelle liaison s'applique.
Correctif 3 : filtre dans la chaîne DOCKER-USER
Certains ports doivent rester publiés, mais seulement pour certaines sources, comme une API interne qu'un partenaire appelle depuis une plage d'adresses fixe. Docker laisse une chaîne vide appelée DOCKER-USER pour tes propres règles, et la traite avant ses propres règles de transfert.
L'exemple de la documentation de Docker rejette le trafic vers les conteneurs sauf s'il vient d'un sous-réseau donné (remplace ext_if par le nom de ton interface externe, comme eth0) :
iptables -I DOCKER-USER -i ext_if ! -s 192.0.2.0/24 -j DROP
iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT
-I insère la règle en haut de la chaîne : la deuxième commande se retrouve donc en premier. Les réponses aux connexions déjà acceptées passent avant que la règle DROP ne soit évaluée.
Il y a une subtilité : au moment où les paquets atteignent DOCKER-USER, l'adresse et le port de destination ont déjà été réécrits. Pour filtrer sur le port auquel les clients se sont connectés à l'origine, utilise le module conntrack :
iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 80 -j ACCEPT
Docker ne sauvegarde pas ces règles à ta place. Rends-les persistantes comme tu le fais pour tes autres règles iptables sur ta distribution, et relance le scan depuis l'extérieur après un redémarrage pour confirmer qu'elles ont survécu.
FAQ
ufw bloque-t-il les conteneurs Docker ?
Pas pour les ports publiés. Docker fait passer ce trafic par le NAT et la chaîne FORWARD : les règles ufw deny habituelles sur INPUT ne s'y appliquent jamais. Les conteneurs sans port publié ne sont pas exposés sur l'adresse publique de l'hôte.
Est-ce sûr de passer iptables à false dans Docker ?
La documentation de Docker dit que cette option ne convient pas à la plupart des utilisateurs et risque de casser le réseau des conteneurs. Sans règles de remplacement, les conteneurs des réseaux bridge ne peuvent pas joindre Internet, et leurs ports restent accessibles aux hôtes du réseau local.
Lier un port Docker à 127.0.0.1 le protège-t-il ?
Avec Docker 28.0.0 et les versions suivantes, un port publié sur 127.0.0.1 n'est joignable que depuis l'hôte lui-même. Avec les versions plus anciennes, les machines du même segment de réseau local pouvaient quand même l'atteindre : vérifie ta version.