Docker contourne UFW : comprendre et corriger le problème
Un port refusé par UFW peut rester accessible sur Internet : Docker le publie via DNAT et iptables. Comprenez le mécanisme et appliquez les deux correctifs fiables.
Pourquoi Docker contourne UFW
Docker contourne UFW, car les ports publiés des conteneurs ne passent jamais par les règles du pare-feu gérées par UFW. Lorsque vous exécutez docker run -p 8080:80, Docker écrit une règle DNAT (traduction d’adresses réseau de destination) dans sa propre chaîne DOCKER de la table nat du noyau, vers laquelle la chaîne PREROUTING redirige les paquets. Cette règle réécrit la destination de chaque paquet avec l’adresse privée du conteneur avant que le noyau détermine où le paquet doit être acheminé. Le paquet réécrit est ensuite transmis au conteneur par la chaîne FORWARD, que Docker contrôle. Les règles d’UFW se trouvent dans la chaîne INPUT, et le paquet n’y entre jamais. Ainsi, ufw status affiche un refus par défaut, sudo ufw deny 8080 indique une réussite, et le port 8080 répond toujours à l’ensemble d’Internet.
Il ne s’agit pas d’un bug de Docker, et UFW ne fonctionne pas mal. Les deux outils configurent le même pare-feu du noyau. Les règles de Docker interviennent simplement plus tôt dans le parcours du paquet, si bien qu’UFW n’est jamais sollicité. Ce guide montre le contournement, explique le mécanisme, puis présente les deux correctifs efficaces : publier les ports sur 127.0.0.1 et filtrer dans la chaîne DOCKER-USER. Si UFW est nouveau pour vous, commencez par le guide de base du pare-feu UFW, car un pare-feu avec refus par défaut reste la bonne base pour le reste du serveur.
Observez le contournement sur votre propre serveur
Commencez par un VPS sur lequel UFW est actif, avec une politique par défaut qui refuse le trafic entrant. Lancez un conteneur web avec un port publié :
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose affiche Default: deny (incoming), allow (outgoing) et aucune règle pour le port 8080. Selon le propre état du firewall, le port est fermé. Testez maintenant depuis une autre machine, et non depuis le serveur lui-même :
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKLe conteneur répond. Ajoutez une règle explicite de refus, puis testez à nouveau :
sudo ufw deny 8080/tcpLe port répond toujours, car la règle de refus se trouve dans une chaîne que le paquet ne traverse jamais. UFW n’a pas échoué. Il n’a jamais été consulté. C’est aussi la raison pour laquelle le problème passe facilement inaperçu : aucune erreur ne s’affiche, le déploiement fonctionne et la sortie de l’état du firewall ressemble exactement à celle d’un serveur correctement verrouillé.
Le mécanisme : PREROUTING s’exécute avant INPUT
Le noyau traite les paquets entrants dans un ordre fixe, et tout le problème vient de cet ordre.
PREROUTINGs’exécute en premier. Les règles atteintes depuis cette chaîne peuvent réécrire la destination du paquet. La règle Docker d’un port publié, dans la chaîneDOCKERvers laquellePREROUTINGredirige, fait exactement cela.- La décision de routage intervient ensuite. Un paquet destiné à l’hôte lui-même passe par la chaîne
INPUT. Un paquet destiné à une autre machine passe par la chaîneFORWARD. - Les règles UFW se trouvent dans
INPUT. Les règles Docker se trouvent dansFORWARD.
Examinez la règle Docker correspondant au conteneur que vous venez de démarrer :
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80La ligne DNAT explique tout. Tout paquet arrivant pour le port 8080 voit sa destination réécrite vers 172.17.0.2:80, l’adresse du conteneur sur le réseau bridge privé de Docker. Après cette réécriture, le paquet n’est plus destiné à l’hôte. La décision de routage l’envoie donc sur le chemin FORWARD, où Docker a déjà ajouté des règles qui autorisent le trafic vers ses propres réseaux. Votre règle deny 8080/tcp attend dans INPUT un paquet qui n’arrive jamais.
Sur Ubuntu 24.04, la commande iptables sert d’interface à nftables, mais l’ordre des chaînes et le résultat sont identiques. UFW et Docker écrivent tous deux dans le même pipeline de traitement des paquets du noyau, et le point d’entrée de Docker intervient plus tôt. Ce problème ne concerne pas uniquement UFW : firewalld sur un VPS Rocky ou AlmaLinux filtre les paquets au même endroit dans ce pipeline et est contourné par la même règle DNAT. Les corrections ci-dessous s’appliquent donc également dans ce cas.
La correction courante : publier les ports sur 127.0.0.1
La plupart des conteneurs n’ont jamais eu besoin d’être accessibles depuis Internet. Une base de données, un serveur applicatif derrière un reverse proxy, un panneau d’administration ou un endpoint de métriques ne doivent pas répondre directement sur Internet. Publiez-les sur l’adresse loopback :
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineOu dans un fichier Compose :
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Cela fonctionne parce que la règle DNAT de Docker ne correspond désormais qu’aux paquets adressés à 127.0.0.1. Avec Docker Engine 28.0.0 et les versions ultérieures, Docker ajoute également une règle dans la table raw, dans la chaîne PREROUTING. Cette règle supprime tout paquet destiné à ce port loopback qui arrive sur une interface autre que loopback. Avec ces versions, le port est accessible depuis l’hôte, et depuis aucun autre endroit. Avec les versions antérieures à 28.0.0, Docker indique que les hôtes du même segment L2, par exemple des machines reliées au même switch réseau, peuvent encore atteindre les ports publiés sur localhost. Vérifiez donc docker version et effectuez la mise à niveau avant de vous fier à ce comportement. Vérifiez la liaison :
sudo ss -tlnp | grep 8080La sortie doit contenir 127.0.0.1:8080, et non 0.0.0.0:8080 ou [::]:8080. Vérifiez ensuite depuis une autre machine que curl http://your-vps-ip:8080/ est refusé.
Pour les services qui doivent être accessibles depuis Internet, exécutez un reverse proxy qui utilise les ports 80 et 443 et qui achemine les requêtes selon le nom d’hôte. Ne publiez aucun autre port. C’est le modèle présenté dans le guide du reverse proxy Traefik, et c’est ainsi qu’une application auto-hébergée comme Nextcloud sur un VPS reste inaccessible autrement que par son proxy. La déclaration des entrées ports: et le reste du workflow Compose sont décrits dans le guide des bases de Docker Compose.
Lorsque tous les conteneurs internes utilisent loopback, UFW reprend son rôle normal : protéger les ports servis par l’hôte lui-même. Construisez cet ensemble de règles ici, puis exécutez les commandes dans l’ordre :
Filtrage réel : la chaîne DOCKER-USER
Un port de conteneur doit parfois rester publié sur le réseau tout en étant restreint. C’est par exemple le cas d’un port de réplication de base de données accessible uniquement depuis l’adresse d’un bureau. Docker fournit la chaîne DOCKER-USER pour cela. Chaque paquet destiné à un conteneur passe par DOCKER-USER avant les propres règles d’acceptation de Docker. Cette chaîne est réservée à vos règles. Docker ne les supprime ni ne les modifie, y compris après un redémarrage du daemon.
Cette chaîne appartient au backend de pare-feu iptables par défaut de Docker. Docker Engine 29 a ajouté un backend nftables expérimental, que vous devez activer avec l’option de daemon firewall-backend. Avec ce backend, la chaîne DOCKER-USER n’existe pas : le filtrage personnalisé doit être placé dans une table nftables distincte que vous créez, avec une chaîne de base sur le hook forward. Si vous n’avez pas modifié cette option, vous utilisez iptables et tout ce qui suit s’applique. La différence entre les deux outils est expliquée dans le guide iptables vs nftables.
Un piège doit être signalé avant la commande : lorsqu’un paquet atteint DOCKER-USER, la réécriture DNAT a déjà eu lieu. Le port de destination du paquet est le port du conteneur (80 dans notre exemple), et non le port publié (8080). Une règle qui correspond à --dport 8080 ne correspond donc à rien. La méthode fiable consiste à utiliser le port composé initialement par le client, que le conntrack du noyau conserve :
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPInterprétez cette règle ainsi : pour les paquets entrés par eth0 et appartenant à une connexion dont le port de destination d’origine était 8080, supprimer tout paquet qui ne provient pas de 10.0.0.10. La correspondance --ctdir ORIGINAL limite la règle au sens client vers conteneur. Les paquets de réponse ne sont donc pas interceptés par erreur. Remplacez eth0 par votre interface publique ; ip route | grep default permet de l’identifier. Effectuez le même test que précédemment : curl depuis l’adresse autorisée réussit, tandis que la connexion expire depuis toute autre adresse. Ce blocage est la signature d’une règle DROP qui fonctionne, et non d’un port sans service derrière lui. La différence entre une connexion refusée et une connexion qui expire est le moyen le plus rapide de distinguer un port filtré d’un service qui n’écoute tout simplement pas.
Les règles ajoutées avec la commande iptables disparaissent au redémarrage. Comme UFW gère déjà ce pare-feu, l’endroit approprié pour les conserver est /etc/ufw/after.rules. Ajoutez un bloc à la fin du fichier :
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITExécutez ensuite sudo ufw reload. UFW rejoue ce fichier à chaque rechargement et à chaque démarrage. Le filtrage de vos conteneurs se trouve ainsi au même endroit que le reste de votre pare-feu et survit à un redémarrage comme à une mise à niveau de Docker.
Pourquoi ne devez-vous pas désactiver l’intégration iptables de Docker
Les anciennes réponses à ce problème suggèrent de définir { "iptables": false } dans /etc/docker/daemon.json. Ne le faites pas. Les règles de pare-feu de Docker font bien plus que publier des ports. La règle de masquerade permet aux conteneurs d’accéder à Internet en sortie via l’adresse de l’hôte. Si l’intégration est désactivée, les conteneurs ne peuvent donc plus accéder aux miroirs de paquets ni appeler une API externe (application programming interface). Les règles DNAT et d’isolation disparaissent également. La documentation de Docker avertit qu’en l’absence de règles de remplacement, les ports des conteneurs deviennent accessibles aux hôtes du réseau local. Vous corrigeriez le contournement en cassant le réseau des conteneurs, et vous devriez écrire et maintenir manuellement chacune de ces règles. La documentation de Docker indique elle-même que ce réglage ne convient pas à la plupart des utilisateurs et décrit la recréation ultérieure de ces règles comme extrêmement complexe. La chaîne DOCKER-USER existe précisément pour que personne n’ait besoin de ce réglage.
Le volet IPv6 du même problème
Commencez par vérifier à quoi ressemble le port publié sur IPv6 :
sudo ss -tlnp | grep 8080Depuis Docker Engine 27, Docker gère ip6tables par défaut. Sur un réseau Docker où IPv6 est activé, un port publié reçoit le même traitement DNAT dans les tables IPv6. Le même contournement existe donc, et le même correctif s’applique : la chaîne DOCKER-USER existe également dans ip6tables. Reproduisez votre règle avec sudo ip6tables -I DOCKER-USER ..., puis testez depuis l’extérieur avec curl vers l’adresse IPv6 publique de votre serveur, par exemple curl -6 http://[2001:db8:2a::1]:8080/. Cela s’applique au backend iptables par défaut de Docker. Avec le backend nftables expérimental, la chaîne DOCKER-USER n’existe dans aucune des deux piles. Le filtrage IPv6 doit alors être effectué dans votre propre table nftables ip6.
Sur un réseau sans IPv6, les clients IPv6 sont pris en charge par docker-proxy, un processus normal en espace utilisateur qui écoute sur [::]:8080 et transmet le trafic au conteneur en IPv4. Le trafic destiné à un processus de l’hôte passe bien par INPUT. UFW peut donc filtrer ce chemin, mais uniquement lorsqu’il gère effectivement IPv6. La configuration concernée et les autres situations pouvant créer une faille IPv6 sur un VPS sont traitées dans le guide UFW et IPv6.
La publication sur loopback évite entièrement cette question : -p 127.0.0.1:8080:80 se lie uniquement à loopback IPv4. Il n’y a donc aucun listener IPv6 et, avec Docker Engine 28.0.0 et les versions ultérieures, rien n’est accessible depuis l’extérieur sur aucune des deux piles.
Le modèle qui tient dans la durée
- Publiez chaque port interne sur
127.0.0.1afin qu’il ne soit jamais exposé, avec Docker Engine 28.0.0 ou version ultérieure (les versions précédentes permettaient aux hôtes du même segment L2 d’y accéder). - Confiez l’exposition publique à un seul reverse proxy qui utilise les ports 80 et 443.
- Conservez la règle par défaut de UFW en mode deny pour l’hôte, en autorisant SSH et les ports du proxy.
- Filtrez les ports de conteneurs réellement publics dans
DOCKER-USERavec le backend iptables par défaut de Docker, en faisant correspondre le port de destination d’origine, puis rendez cette configuration persistante dans/etc/ufw/after.rules. - Laissez l’intégration iptables de Docker activée.
Une fois cette configuration effectuée, les rôles sont clairs : ufw status décrit l’hôte et DOCKER-USER décrit les conteneurs. Aucun port n’est publié par accident, et le prochain docker run -p que vous saisirez exposera exactement ce que vous avez prévu.
FAQ
Pourquoi puis-je atteindre mon conteneur Docker alors qu’UFW bloque le port ?
Parce que Docker publie le port avec une règle DNAT dans sa chaîne DOCKER de la table nat, atteinte depuis PREROUTING. Cette règle réécrit la destination du paquet vers l’adresse du conteneur avant tout filtrage. Le paquet suit ensuite le chemin FORWARD, tandis que les règles UFW se trouvent dans INPUT, une chaîne que le paquet ne traverse jamais. Le pare-feu n’est donc jamais consulté et ses règles de refus n’ont aucun effet sur les ports publiés des conteneurs.
Comment faire en sorte qu’UFW bloque les ports publiés par Docker ?
UFW ne peut pas le faire directement, car ses règles se trouvent dans la mauvaise chaîne. Vous pouvez soit cesser d’exposer le port en le publiant avec 127.0.0.1:8080:80, afin que seul l’hôte puisse l’atteindre avec Docker Engine 28.0.0 et les versions ultérieures, soit filtrer le trafic dans la chaîne DOCKER-USER avec une règle iptables qui correspond au port de destination d’origine via conntrack. Enregistrez cette règle dans /etc/ufw/after.rules afin qu’elle soit conservée après les redémarrages et ufw reload.
Dois-je définir "iptables": false dans le fichier daemon.json de Docker ?
Non. Ce paramètre supprime la plupart des règles de pare-feu et de NAT de Docker, ce qui provoque bien plus de problèmes que le simple contournement d’UFW. Les conteneurs perdent leur accès sortant à Internet, car la règle de masquerade disparaît. Docker avertit également que, sans règles de remplacement, les ports des conteneurs deviennent accessibles depuis les hôtes du réseau local. Utilisez plutôt la publication sur loopback et la chaîne DOCKER-USER. Elles corrigent l’exposition sans casser le réseau des conteneurs.
Docker contourne-t-il aussi UFW avec IPv6 ?
Avec Docker Engine 27 et les versions ultérieures, la gestion d’ip6tables est activée par défaut. Un port publié sur un réseau Docker compatible IPv6 est donc réécrit en contournant UFW, comme avec IPv4. Avec le backend iptables par défaut de Docker, il nécessite la même règle DOCKER-USER, reproduite avec ip6tables. Avec le backend nftables expérimental d’Engine 29, il n’existe pas de chaîne DOCKER-USER. Le filtrage doit donc être placé dans votre propre table nftables. Sur les réseaux sans IPv6, le processus docker-proxy écoute sur [::] et ce trafic traverse bien INPUT. UFW peut alors le filtrer s’il gère IPv6. La publication sur 127.0.0.1 évite ces deux cas, car aucun processus n’écoute sur IPv6.