SSD Nodes Learn Hosting plans →
Guides Matt ConnorPar Matt Connor · Mis à jour le 2026-08-22

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 parce que les ports publiés des conteneurs ne passent jamais par les règles de pare-feu gérées par UFW. Lorsque vous exécutez docker run -p 8080:80, Docker écrit une règle DNAT (traduction d’adresse réseau de destination) 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étermine où le paquet doit aller. 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. Ainsi, ufw status affiche un refus par défaut, sudo ufw deny 8080 indique une exécution réussie, et le port 8080 répond toujours à l’ensemble d’Internet.

Il ne s’agit pas d’un bug de Docker, et UFW n’est pas défaillant. Les deux outils configurent le même pare-feu du noyau. Les règles de Docker interviennent simplement plus tôt sur le chemin du paquet, si bien qu’UFW n’est jamais consulté. Ce guide montre ce contournement, explique le mécanisme, puis présente les deux correctifs qui fonctionnent : publier les ports sur 127.0.0.1 et filtrer dans la chaîne DOCKER-USER. Si UFW vous est encore inconnu, commencez par le configurer avec le guide de base du pare-feu UFW, car un pare-feu qui refuse par défaut reste la bonne base pour tout 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-alpine

ufw 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 OK

Le conteneur répond. Ajoutez une règle explicite de refus, puis testez à 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 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. Tout le problème vient de cet ordre.

  1. PREROUTING s’exécute en premier. Les règles de cette chaîne peuvent réécrire la destination du paquet, et la règle Docker d’un port publié fait exactement cela.
  2. 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îne FORWARD.
  3. Les règles UFW se trouvent dans INPUT. Les règles Docker se trouvent dans FORWARD.

Examinez la règle Docker correspondant au 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 explique tout. Tout paquet arrivant sur 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 est une interface frontale pour 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 est situé plus tôt. Ce comportement 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 correctifs ci-dessous sont donc également ceux à utiliser 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 publiquement. Une base de données, un serveur d’application 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-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 destinés à 127.0.0.1. Un paquet provenant d’Internet ne peut jamais légitimement avoir cette destination. Le kernel le supprime donc avant l’exécution de toute règle de pare-feu. Le port est accessible depuis l’hôte, et depuis aucun autre emplacement. Vérifiez le binding :

sudo ss -tlnp | grep 8080

La 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, utilisez un seul reverse proxy qui possède les ports 80 et 443 et effectue le routage selon le hostname. Ne publiez aucun autre port. C’est le modèle présenté dans le guide du reverse proxy Traefik. C’est aussi 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 présentés 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 directement par l’hôte. Construisez cet ensemble de règles ici, puis exécutez les commandes dans l’ordre :

ToolUFW rule generator

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 notamment le cas d’un port de réplica 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. Docker n’écrit jamais de règles dans cette chaîne. Elle vous est réservée et Docker ne modifie pas son contenu lors des redémarrages du daemon.

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 celui du conteneur, soit 80 dans notre exemple, et non le port publié, soit 8080. Une règle qui correspond à --dport 8080 ne correspond donc à aucun paquet. La méthode fiable consiste à vérifier le port composé à l’origine par le client, que le connection tracker du noyau conserve :

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

Cette règle signifie que, pour les paquets entrés par eth0 et appartenant à une connexion dont le port de destination d’origine était 8080, tout paquet qui ne provient pas de 10.0.0.10 est rejeté. Le match --ctdir ORIGINAL limite la règle au sens client-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 doit réussir, tandis que, depuis toute autre adresse, la connexion expire. Cette attente est le signe qu’une règle DROP fonctionne, et non qu’aucun service n’est disponible derrière le port. 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 firewall, l’emplacement 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
COMMIT

Exé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 firewall et survit à la fois à un redémarrage et à une mise à niveau de Docker.

Pourquoi ne pas désactiver l’intégration d’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 ne servent pas uniquement à publier des ports. La règle de masquerade permet aux conteneurs d’accéder à Internet en sortie avec l’adresse de l’hôte. Si l’intégration est désactivée, les conteneurs ne peuvent donc plus télécharger d’images, accéder aux miroirs de paquets ni appeler une API externe (application programming interface). Les règles DNAT permettent à -p de fonctionner. Les ports publiés cessent donc entièrement de fonctionner. Les règles d’isolation qui séparent les différents réseaux Compose disparaissent également. 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 présente ce paramètre comme destiné aux personnes qui veulent précisément procéder ainsi. La chaîne DOCKER-USER existe justement pour que personne n’ait besoin de ce commutateur.

Le volet IPv6 du même problème

Commencez par vérifier à 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 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/.

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 via 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 manière de le vérifier, ainsi que les autres façons dont une faille IPv6 peut apparaître sur un VPS, sont expliquées dans le guide UFW et IPv6.

La publication sur loopback évite toute cette question : -p 127.0.0.1:8080:80 se lie uniquement à loopback IPv4. Il n’y a donc aucun listener IPv6 et rien n’est accessible depuis l’extérieur, quelle que soit la pile utilisée.

Le modèle qui tient dans la durée

  • Publiez chaque port interne sur 127.0.0.1 afin qu’il ne soit jamais exposé.
  • Confiez la partie publique à un seul reverse proxy qui utilise les ports 80 et 443.
  • Conservez la règle par défaut deny d’UFW pour l’hôte et autorisez SSH ainsi que les ports du proxy.
  • Filtrez les ports des conteneurs réellement publics dans DOCKER-USER, en faisant correspondre le port de destination d’origine, puis rendez ces règles persistantes dans /etc/ufw/after.rules.
  • Laissez l’intégration iptables de Docker activée.

Une fois cette configuration en place, les comportements inattendus disparaissent : ufw status décrit l’hôte et DOCKER-USER décrit les conteneurs. Aucun port n’est publié par accident, et la prochaine commande docker run -p que vous saisissez expose exactement ce que vous avez prévu.

FAQ

Pourquoi puis-je atteindre mon conteneur Docker alors que 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 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 que 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 arrêter d’exposer le port en le publiant sur 127.0.0.1:8080:80, afin que seul l’hôte puisse y accéder, soit filtrer dans la chaîne DOCKER-USER avec une règle iptables qui fait correspondre le port de destination d’origine via conntrack. Enregistrez cette règle dans /etc/ufw/after.rules afin qu’elle survive aux redémarrages et à ufw reload.

Dois-je définir "iptables": false dans le fichier daemon.json de Docker ?

Non. Ce paramètre supprime toutes les règles de pare-feu et de NAT de Docker, ce qui casse bien plus que le contournement d’UFW. Les conteneurs perdent leur accès sortant à Internet, car la règle de masquerade disparaît, et les ports publiés cessent de fonctionner, car les règles DNAT sont supprimées. Utilisez plutôt la publication sur loopback et la chaîne DOCKER-USER. Elles corrigent l’exposition sans perturber le réseau des conteneurs.

Docker contourne-t-il aussi UFW avec IPv6 ?

À partir de Docker Engine 27, la gestion d’ip6tables est activée par défaut. Un port publié sur un réseau Docker compatible IPv6 est donc réécrit autour d’UFW comme avec 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 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.