SSD Nodes Learn
Guides Matt ConnorPar Matt Connor · Mis à jour le 2026-07-19

Docker contourne UFW : causes et solutions

Docker publie les ports des conteneurs via des règles iptables qui ignorent UFW, donc un port refusé reste ouvert sur internet. Le mécanisme et les correctifs.

Pourquoi Docker contourne UFW

Docker contourne UFW parce que les ports publiés des conteneurs ne passent jamais par les règles de pare-feu que gère UFW. Quand vous lancez docker run -p 8080:80, Docker écrit une règle DNAT (traduction d'adresse réseau de destination, ou destination network address translation) dans la chaîne PREROUTING de la table nat du noyau. Cette règle réécrit la destination de chaque paquet vers l'adresse privée du conteneur avant que le noyau ne décide où va le paquet. Le paquet réécrit est ensuite transféré vers le 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. Donc ufw status affiche un refus par défaut, sudo ufw deny 8080 signale une réussite, et le port 8080 répond toujours à tout l'internet.

Ce n'est pas un bug de Docker, et UFW n'est pas cassé. Les deux outils programment le même pare-feu du noyau. Les règles de Docker agissent simplement à un point plus précoce sur le chemin du paquet, donc UFW n'est jamais sollicité. Ce guide démontre le contournement, explique le mécanisme, puis couvre les deux solutions qui fonctionnent : publier les ports sur 127.0.0.1, et filtrer dans la chaîne DOCKER-USER. Si UFW lui-même est nouveau pour vous, configurez-le d'abord avec le guide des bases du pare-feu UFW, car un pare-feu en refus par défaut reste la bonne base pour tout le reste sur le serveur.

Observez le contournement sur votre propre serveur

Partez d'un VPS où UFW est actif avec une politique de refus par défaut pour 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-alpine

ufw status verbose affiche Default: deny (incoming), allow (outgoing) et aucune règle pour le port 8080. D'après le rapport du pare-feu lui-même, le port est fermé. Testez maintenant depuis une autre machine, pas depuis le serveur lui-même :

curl -I http://your-vps-ip:8080/
HTTP/1.1 200 OK

Le conteneur répond. Ajoutez une règle de refus explicite et testez de nouveau :

sudo ufw deny 8080/tcp

Le port répond toujours, car la règle de refus se trouve dans une chaîne que le paquet ne visite jamais. UFW n'a pas échoué. Il n'a jamais été consulté. C'est aussi pourquoi le problème se cache si bien : aucune erreur n'est affichée nulle part, le déploiement fonctionne, et la sortie d'état du pare-feu ressemble exactement à celle d'un serveur sain et verrouillé.

Le mécanisme : PREROUTING s'exécute avant INPUT

Le noyau traite un paquet entrant dans un ordre fixe, et tout le problème réside dans cet ordre.

  1. PREROUTING s'exécute en premier. Les règles ici peuvent réécrire la destination du paquet, et c'est exactement ce que fait la règle de Docker pour un port publié.
  2. La décision de routage vient ensuite. Un paquet adressé à l'hôte lui-même va vers la chaîne INPUT. Un paquet adressé à toute autre machine va vers la chaîne FORWARD.
  3. Les règles d'UFW se trouvent dans INPUT. Les règles de Docker se trouvent dans FORWARD.

Regardez la règle de Docker pour le conteneur que vous venez de démarrer :

sudo iptables -t nat -L DOCKER -n
Chain 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:80

La ligne DNAT est toute l'histoire. 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 la réécriture, le paquet n'est plus adressé à l'hôte, donc la décision de routage l'envoie sur le chemin FORWARD, où Docker a déjà ajouté des règles qui acceptent 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 est une interface au-dessus de nftables, mais l'ordre des chaînes et le résultat sont identiques. UFW et Docker écrivent tous les deux dans le même pipeline de paquets du noyau, et le point d'entrée de Docker est plus précoce.

La solution du quotidien : publier les ports sur 127.0.0.1

La plupart des conteneurs n'avaient de toute façon pas besoin d'être publics. Une base de données, un serveur d'application derrière un proxy inverse, un panneau d'administration, un point de terminaison de métriques : aucun de ceux-ci ne devrait répondre directement à internet. Publiez-les sur l'adresse de bouclage (loopback) :

docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpine

Ou 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, et un paquet venant d'internet ne peut jamais légitimement porter cette destination, donc le noyau le rejette avant qu'aucune règle de pare-feu ne s'exécute. Le port est accessible depuis l'hôte, et depuis rien d'autre. Vérifiez la liaison :

sudo ss -tlnp | grep 8080

Vous voulez 127.0.0.1:8080 dans la sortie, pas 0.0.0.0:8080 ni [::]:8080. Confirmez ensuite depuis une autre machine que curl http://your-vps-ip:8080/ est refusé.

Pour les services qui doivent faire face à internet, exécutez un seul proxy inverse qui possède les ports 80 et 443 et route selon le nom d'hôte, et ne publiez rien d'autre. C'est le modèle que construit le guide du proxy inverse Traefik, et c'est ainsi qu'une application auto-hébergée comme Nextcloud sur un VPS reste injoignable sauf via son proxy. La façon dont les entrées ports: sont déclarées, et le reste du flux de travail Compose, est couverte dans le guide des bases de Docker Compose.

Avec chaque conteneur interne sur le bouclage, UFW revient à son travail normal : garder les ports que l'hôte lui-même sert. Construisez ce jeu de règles ici, puis lancez les commandes dans l'ordre :

ToolGénérateur de règles UFW

Filtrage réel : la chaîne DOCKER-USER

Parfois, un port de conteneur doit rester publié sur le réseau mais restreint, par exemple un port de réplica de base de données qu'une seule adresse de bureau peut atteindre. Pour cela, Docker fournit la chaîne DOCKER-USER. Chaque paquet se dirigeant vers un conteneur passe par DOCKER-USER avant les propres règles d'acceptation de Docker, et Docker n'y écrit jamais de règles. La chaîne existe pour les vôtres, et Docker laisse son contenu tranquille à travers les redémarrages du démon.

Un piège avant la commande : au moment où 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), pas le port publié (8080). Une règle qui correspond à --dport 8080 ne correspond donc à rien. La méthode fiable est de faire correspondre le port que le client a composé à l'origine, dont le suivi de connexion (conntrack) du noyau se souvient :

sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP

Lisez-la ainsi : pour les paquets qui sont entrés sur eth0 et qui appartiennent à une connexion dont le port de destination d'origine était 8080, rejeter tout ce qui n'est pas envoyé depuis 10.0.0.10. La correspondance --ctdir ORIGINAL limite la règle à la direction client vers conteneur, donc les paquets de réponse ne sont pas attrapés par erreur. Remplacez eth0 par votre interface publique ; ip route | grep default la nomme. Testez-la de la même façon qu'avant : curl depuis l'adresse autorisée réussit, et depuis partout ailleurs la connexion expire.

Les règles ajoutées avec la commande iptables disparaissent au redémarrage. Puisque UFW gère déjà ce pare-feu, l'endroit propre pour les rendre persistantes 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
COMMIT

Puis lancez sudo ufw reload. UFW rejoue ce fichier à chaque rechargement et à chaque démarrage, donc votre filtrage de conteneur vit maintenant au même endroit que le reste de votre pare-feu, et il survit à la fois à un redémarrage et à une mise à niveau de Docker.

Pourquoi vous ne devriez pas désactiver l'intégration iptables de Docker

Des réponses plus anciennes à 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 mascarade (masquerade) est ce qui donne aux conteneurs un accès sortant à internet via l'adresse de l'hôte, donc avec l'intégration désactivée, les conteneurs ne peuvent pas télécharger d'images, atteindre les miroirs de paquets, ni appeler aucune API externe (interface de programmation d'application, ou application programming interface). Les règles DNAT sont ce qui fait fonctionner -p, donc les ports publiés cessent complètement de fonctionner. Les règles d'isolation qui séparent les réseaux Compose distincts disparaissent aussi. Vous corrigeriez le contournement en cassant le réseau des conteneurs, et chacune de ces règles deviendrait la vôtre à écrire et à maintenir à la main. La documentation de Docker elle-même décrit ce réglage comme destiné aux personnes qui ont l'intention de faire exactement cela. La chaîne DOCKER-USER existe précisément pour que personne n'ait besoin de cet interrupteur.

Le versant IPv6 du même problème

Vérifiez d'abord à quoi ressemble le port publié sur IPv6 :

sudo ss -tlnp | grep 8080

Depuis Docker Engine 27, Docker gère ip6tables par défaut. Sur un réseau Docker avec IPv6 activé, un port publié reçoit le même traitement DNAT dans les tables IPv6, donc le même contournement existe là aussi et la même solution s'applique : la chaîne DOCKER-USER existe aussi dans ip6tables, donc reproduisez votre règle avec sudo ip6tables -I DOCKER-USER ... et testez depuis l'extérieur avec curl contre l'adresse IPv6 publique de votre serveur, par exemple curl -6 http://[2001:db8:2a::1]:8080/.

Sur un réseau sans IPv6, les clients IPv6 sont pris en charge à la place par docker-proxy, un processus normal en espace utilisateur qui écoute sur [::]:8080 et transfère le trafic vers le conteneur via IPv4. Le trafic vers un processus de l'hôte passe bien par INPUT, donc UFW peut filtrer ce chemin, mais seulement quand UFW gère IPv6. La question de savoir s'il le fait, et les autres façons dont une faille IPv6 s'ouvre sur un VPS, est le sujet de le guide UFW et IPv6.

Publier sur le bouclage esquive toute la question : -p 127.0.0.1:8080:80 lie uniquement le bouclage IPv4, donc il n'y a aucun écouteur IPv6 et rien à atteindre depuis l'extérieur sur l'une ou l'autre pile.

Le modèle qui tient

  • Publiez chaque port interne sur 127.0.0.1, pour qu'il ne soit jamais exposé en premier lieu.
  • Confiez le côté public à un seul proxy inverse qui possède les ports 80 et 443.
  • Gardez UFW en refus par défaut pour l'hôte, en autorisant SSH et les ports du proxy.
  • Filtrez les ports de conteneur réellement publics dans DOCKER-USER, en correspondant sur le port de destination d'origine, rendus persistants dans /etc/ufw/after.rules.
  • Laissez l'intégration iptables de Docker activée.

Configuré une fois, cela supprime la surprise : ufw status décrit l'hôte, et DOCKER-USER décrit les conteneurs. Rien n'est publié par accident, et le prochain docker run -p que vous tapez expose exactement ce que vous vouliez.

FAQ

Pourquoi puis-je atteindre mon conteneur Docker quand UFW bloque le port ?

Parce que Docker publie le port avec une règle DNAT dans la chaîne PREROUTING, qui réécrit la destination du paquet vers l'adresse du conteneur avant tout filtrage. Le paquet parcourt ensuite le chemin FORWARD, et les règles d'UFW se trouvent dans INPUT, une chaîne où le paquet n'entre jamais. Le pare-feu n'est jamais consulté, donc ses règles de refus n'ont aucun effet sur les ports de conteneur publiés.

Comment faire pour qu'UFW bloque les ports publiés par Docker ?

UFW lui-même ne le peut pas, car ses règles sont dans la mauvaise chaîne. Soit cessez d'exposer le port, en le publiant comme 127.0.0.1:8080:80 pour que seul l'hôte puisse l'atteindre, soit filtrez dans la chaîne DOCKER-USER avec une règle iptables qui correspond au port de destination d'origine via conntrack. Rendez cette règle persistante dans /etc/ufw/after.rules pour qu'elle survive aux redémarrages et à ufw reload.

Faut-il définir "iptables": false dans le daemon.json de Docker ?

Non. Ce réglage supprime toutes les règles de pare-feu et de NAT de Docker, ce qui casse bien plus que le contournement. Les conteneurs perdent leur accès sortant à internet parce que la règle de mascarade a disparu, et les ports publiés cessent de fonctionner parce que les règles DNAT ont disparu. Utilisez plutôt la publication sur le bouclage et la chaîne DOCKER-USER ; elles corrigent l'exposition sans casser le réseau des conteneurs.

Docker contourne-t-il UFW sur IPv6 aussi ?

Sur Docker Engine 27 et versions ultérieures, la gestion d'ip6tables est activée par défaut, donc un port publié sur un réseau Docker avec IPv6 activé est réécrit autour d'UFW exactement comme sur IPv4, et nécessite la même règle DOCKER-USER reproduite avec ip6tables. Sur les réseaux sans IPv6, le processus docker-proxy écoute sur [::] et ce trafic passe bien par INPUT, où UFW peut le filtrer si UFW gère IPv6. Publier sur 127.0.0.1 évite les deux cas, parce que rien n'écoute sur IPv6 du tout.