Docker et VPN : pourquoi les ports disparaissent
Avec Gluetun et network_mode: service, les ports et le nom DNS disparaissent. Découvrez l’erreur Docker et le fichier Compose corrigé pour les publier au bon endroit.
Pourquoi les ports disparaissent lorsque vous faites passer des conteneurs Docker par un VPN
Pour faire passer des conteneurs Docker par un VPN, vous affectez le tunnel à un conteneur, puis vous rattachez les autres à son espace de noms réseau avec network_mode: "service:gluetun". C’est ce rattachement qui surprend souvent. Le conteneur rattaché n’a plus son propre réseau. Ses ports publiés et son nom de service Docker disparaissent donc avec lui. Publiez les ports sur le conteneur VPN. Les autres conteneurs accèdent alors à l’application avec le nom du conteneur VPN.
Si vous laissez un bloc ports: sur le conteneur rattaché, Docker refuse même de le créer :
Error response from daemon: conflicting options: port publishing and the container type network modeL’outil utilisé ici est Gluetun, un conteneur qui se connecte à un fournisseur de VPN (réseau privé virtuel) commercial via WireGuard ou OpenVPN et qui intègre son propre firewall. La version v3.41.3 est la version actuelle en août 2026. Les exemples utilisent Mullvad avec WireGuard. Vous avez donc besoin d’un compte et d’une clé fournis par votre fournisseur. Si vous préférez terminer le tunnel sur du matériel que vous contrôlez, lancer votre propre serveur WireGuard sur un VPS permet de configurer l’autre extrémité, tandis que wg-easy dans Docker fournit une interface web pour cette configuration.
Ce que fait réellement network_mode: "service:gluetun"
Chaque conteneur Docker reçoit normalement son propre namespace réseau : ses propres interfaces, table de routage, règles de pare-feu et sockets en écoute. Le mode service: ignore cette étape et démarre le conteneur dans le namespace de gluetun. Un seul namespace signifie une seule adresse IP, ce qui entraîne six conséquences.
- L’application n’a pas sa propre adresse. Elle utilise l’adresse de gluetun.
- L’application n’est connectée à aucun réseau Docker. Son nom de service n’est donc jamais enregistré et ne peut jamais être résolu. Les autres conteneurs doivent utiliser
gluetun. - Les conteneurs présents dans le namespace communiquent entre eux via
localhost. - Deux conteneurs dans un même namespace ne peuvent pas écouter sur le même port. La documentation de Gluetun est catégorique : il n’existe aucune solution de contournement.
- Les capabilities appartiennent à un conteneur, pas à un namespace. Gluetun détient
NET_ADMINet/dev/net/tun, car il crée l’interface du tunnel. Le conteneur attaché ne les hérite pas. - Compose rejette tout fichier dans lequel un service définit à la fois
network_modeetnetworks. Connectez gluetun à vos réseaux, et l’application les utilisera indirectement.
Redémarrer gluetun déconnecte tout ce qui lui est attaché. Ce comportement est documenté. C’est pourquoi gluetun redémarre le processus VPN à l’intérieur du conteneur au lieu de quitter lorsque la connexion échoue. Après avoir redémarré ou recréé gluetun vous-même, redémarrez les conteneurs qui lui sont attachés.
Le fichier Compose fonctionnel
services:
gluetun:
image: qmcgaw/gluetun:v3
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- SERVER_CITIES=Amsterdam
- TZ=Europe/Amsterdam
env_file:
- ./gluetun.env
volumes:
- ./gluetun:/gluetun
ports:
- 127.0.0.1:8080:8080/tcp
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
network_mode: "service:gluetun"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Amsterdam
- WEBUI_PORT=8080
volumes:
- ./qbittorrent:/config
- ./downloads:/downloads
depends_on:
gluetun:
condition: service_healthy
restart: unless-stoppedLe tag :v3 correspond à la dernière version stable de la série v3. Le tag :latest pointe vers le dernier commit de la branche master, qui correspond à la version de développement. Épinglez donc :v3 sur une machine que vous ne voulez pas devoir déboguer un mardi.
WEBUI_PORT=8080 doit correspondre au port publié, car qBittorrent s’attache dans le namespace de gluetun et la règle de publication transmet le trafic de l’hôte au port 8080 dans ce namespace. Si vous modifiez un nombre sans modifier l’autre, le port ne répond pas. 127.0.0.1:8080:8080 maintient l’interface web sur l’adresse loopback de l’hôte. Un 8080:8080 seul publie le port sur toutes les interfaces et ajoute sa propre règle de pare-feu. C’est ainsi que les ports publiés par Docker contournent directement ufw.
Démarrez les conteneurs, puis vérifiez-les dans cet ordre :
docker compose up -d
docker compose ps
docker compose logs gluetun | tail -30docker compose ps doit afficher gluetun avec l’état healthy et qbittorrent avec l’état running. Vérifiez ensuite l’adresse de sortie depuis le namespace. C’est le contrôle qui détermine tout le reste :
docker run --rm --network=container:gluetun alpine:3.22 sh -c "apk add wget && wget -qO- https://ipinfo.io"Le champ ip de ce JSON doit contenir l’adresse de votre fournisseur VPN. S’il contient l’adresse de votre propre serveur, l’application n’est pas dans le tunnel. Rien de ce qui suit ne fonctionnera alors comme décrit.
Conservez les clés en dehors du fichier Compose
gluetun.env contient les identifiants et ne doit pas être versionné dans git :
WIREGUARD_PRIVATE_KEY=wOEI9rqqbDwnN8/Bpp22sVz48T71vJ4fYmFWujulwUU=
WIREGUARD_ADDRESSES=10.64.222.21/32Les deux valeurs proviennent d’un fichier de configuration WireGuard que vous générez dans l’espace client de votre fournisseur. Définissez ses permissions sur 600. Soyez clair sur ce que cela apporte : la clé reste en dehors de votre dépôt, mais docker inspect gluetun affiche toujours toutes les variables d’environnement à toute personne pouvant accéder au socket Docker. Fichiers d’environnement et secrets dans Docker Compose présente des options plus robustes.
Communication entre un conteneur situé à l’extérieur du tunnel et un conteneur situé à l’intérieur
Les deux directions fonctionnent, et chacune utilise un nom différent. Les deux conteneurs doivent partager un réseau Docker. Il s’agit du réseau de gluetun, puisque le conteneur qui lui est rattaché n’a pas de réseau propre. La section Fonctionnement des réseaux Docker Compose décrit les valeurs par défaut.
De l’extérieur vers l’intérieur, utilisez le nom de gluetun et le port sur lequel l’application est en écoute. Un conteneur de reverse proxy accède à l’interface web de qBittorrent via gluetun:8080. Aucune entrée ports: n’est nécessaire, car le trafic entre conteneurs reste sur le réseau Docker et ne passe jamais par un port de l’hôte.
De l’intérieur vers l’extérieur, utilisez le nom de service de l’autre conteneur, par exemple postgres:5432. Depuis la version v3.41, Gluetun résout les noms des autres conteneurs depuis son propre namespace. Utilisez donc cette version ou une version ultérieure si un nom ne se résout pas.
Le pare-feu de Gluetun détermine qui peut ouvrir une connexion vers celui-ci. Le trafic provenant du réseau Docker propre à gluetun est autorisé. Un client situé sur un autre sous-réseau, un ordinateur portable sur votre réseau local ou un conteneur sur un réseau bridge distinct est bloqué tant que vous n’avez pas déclaré ce sous-réseau :
FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24La signification documentée est précise : il s’agit d’une liste de sous-réseaux séparés par des virgules auxquels Gluetun et les conteneurs qui partagent sa pile réseau sont autorisés à accéder.
Les connexions entrantes depuis Internet constituent un problème distinct. Les pairs du client BitTorrent arrivent par le VPN. Publier le port 6881 sur l’hôte ne leur sert donc à rien. Vous avez besoin d’un port redirigé par votre fournisseur, puis de l’indiquer dans FIREWALL_VPN_INPUT_PORTS, qui autorise les ports du côté du serveur VPN. C’est l’élément que la plupart des stacks multimédias construits avec Docker Compose laissent mal configuré.
Le coupe-circuit : que se passe-t-il lorsque le tunnel tombe
Ce schéma justifie sa complexité en cas de panne. Le conteneur rattaché ne dispose d’aucune route secondaire. Son seul chemin vers l’extérieur passe par l’espace de noms qu’il partage. Lorsque le tunnel est arrêté, il n’y a donc aucune solution de repli. Le pare-feu de Gluetun impose la même règle de l’autre côté : le trafic sortant passe par le tunnel ou par le point de terminaison du serveur VPN, et tout le reste est bloqué. Aucun intervalle ne permet aux paquets de sortir par l’interface réseau directe pendant la reconnexion du client.
Gluetun surveille sa propre connexion. Toutes les minutes, il envoie une requête d’écho ICMP (un ping) aux adresses définies dans HEALTH_ICMP_TARGET_IPS, qui vaut par défaut 1.1.1.1,8.8.8.8. Toutes les cinq minutes, il établit une connexion TCP et TLS (Transport Layer Security) complète vers HEALTH_TARGET_ADDRESSES, qui vaut par défaut cloudflare.com:443,github.com:443. En cas d’échec, il redémarre le VPN dans le conteneur et l’inscrit dans les journaux :
WARN [vpn] restarting VPN because it failed to pass the healthcheck: periodic check: dialing: dial tcp4: lookup cloudflare.com: i/o timeoutConsultez les journaux du conteneur rattaché en tenant compte de cet ordre des événements. Des lignes telles que connection refused, operation not permitted et i/o timeout dans l’application sont les conséquences d’un tunnel arrêté, pas les causes. La documentation de Gluetun l’indique explicitement, car on signale souvent la conséquence avant de chercher sa cause pendant des heures.
HEALTH_RESTART_VPN=on est activé par défaut et doit le rester. Désactivez-le uniquement pour diagnostiquer une panne précise, car lorsqu’il est désactivé, un tunnel arrêté le reste.
Ordre de démarrage : empêcher la stack de démarrer avant que le tunnel soit opérationnel
L’image fournit un healthcheck Docker :
HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=1 CMD /gluetun-entrypoint healthcheckCette commande lance une seconde instance temporaire de gluetun, qui interroge le serveur de santé de l’instance en cours d’exécution sur http://127.0.0.1:9999/. Un tunnel opérationnel répond 200 OK. Un tunnel défaillant répond 500 Internal server error avec une chaîne d’erreur, et le conteneur est marqué comme non sain dès le premier échec.
condition: service_healthy attend cette condition. La condition simple depends_on: [gluetun] attend uniquement que le conteneur démarre. Celui-ci démarre plusieurs secondes avant la fin du handshake. L’application démarre donc avec un réseau inactif et abandonne souvent sa première tentative de connexion. Les healthchecks dans Docker Compose présente la syntaxe et les paramètres de temporisation.
Une limite peut facilement passer inaperçue. Compose évalue cette condition une seule fois, lors de la création du conteneur. Il n’arrête pas et ne redémarre pas l’application si gluetun devient ensuite non sain. L’auto-réparation interne de gluetun couvre ce cas. C’est pourquoi gluetun redémarre le processus VPN plutôt que le conteneur.
Vérifiez l’absence de fuite DNS avant de faire confiance à la configuration
Le DNS (domain name system) est la fuite qui subsiste malgré un tunnel correctement configuré. Gluetun exécute son propre resolver dans le namespace et transmet par défaut les requêtes via DoT (DNS over TLS) à Cloudflare : DNS_UPSTREAM_RESOLVER_TYPE=dot et DNS_UPSTREAM_RESOLVERS=cloudflare. Ne modifiez pas ces deux paramètres : vos résolutions sont ainsi chiffrées et passent par le tunnel.
Le paramètre qui provoque cette fuite est DNS_UPSTREAM_PLAIN_ADDRESSES. On l’utilise lorsqu’un nom ne se résout pas et que l’on veut laisser le routeur ou le resolver du fournisseur répondre à la place. La documentation de Gluetun indique clairement la conséquence : tout le trafic DNS ne passera pas par le tunnel VPN et fuira à l’extérieur. Votre trafic reste privé. Votre liste de noms d’hôte ne l’est pas. La même erreur avec WireGuard est décrite dans DNS qui cesse de fonctionner via un tunnel WireGuard.
Pour effectuer le test, définissez HTTPPROXY=on sur gluetun et publiez 8888:8888/tcp, puis configurez un navigateur pour utiliser ce proxy et ouvrez un test de fuite DNS. Le résultat doit indiquer votre fournisseur ou Cloudflare, jamais votre routeur domestique. La documentation de Gluetun avertit que certains tests de fuite peuvent afficher des résultats inhabituels, car le resolver dans le namespace est un intermédiaire local avec cache, et non le serveur qui fournit finalement la réponse. Considérez un pays incorrect ou le resolver de votre propre FAI comme le véritable signal.
Ajouter Tailscale à côté du sidecar VPN, et déterminer lequel prend le dessus
Tailscale est un réseau overlay basé sur WireGuard qui permet d’atteindre vos propres machines. Certains l’exécutent à côté d’un VPN fourni par un prestataire afin de conserver un accès d’administration à la stack. Les deux entrent rarement en conflit, pour une raison qu’il faut comprendre. La documentation de Tailscale précise le comportement par défaut : Tailscale agit comme un réseau overlay, ne route le trafic qu’entre les appareils qui l’exécutent et ne modifie pas votre trafic Internet public.
La réponse dépend donc d’un seul réglage.
- Tailscale dans son propre conteneur, avec la configuration par défaut : il ne voit jamais le trafic sortant de l’application. Gluetun l’achemine entièrement. Tailscale atteint l’application via
gluetun:8080, comme n’importe quel autre conteneur externe. - Tailscale attaché au namespace de gluetun avec
network_mode: "service:gluetun": il lui faut son proprecap_adddenet_adminetnet_raw, car les capabilities ne sont pas héritées du namespace. Avec le mode de networking userspace par défaut,TS_USERSPACEest activé, tailscaled ne crée aucune interface et fonctionne comme proxy SOCKS5 ou HTTP ; il ne peut donc pas modifier le routage. Gluetun continue d’acheminer tout le trafic. - Même configuration, avec
TS_USERSPACE=false: tailscaled crée un tunnel device et installe des routes, mais uniquement pour la plage du tailnet100.64.0.0/10et pour les routes de sous-réseau que vous annoncez avecTS_ROUTES. Le trafic public continue de sortir via gluetun. - L’une des configurations précédentes avec un exit node sélectionné,
sudo tailscale set --exit-node=<exit-node-ip>: Tailscale prend la route par défaut et devient prioritaire. Ne combinez pas cette configuration avec gluetun. Une route par défaut, un seul propriétaire.
Si ces routes annoncées sont l’objectif et que vous voulez rendre accessible tout le réseau privé derrière la machine, plutôt que la machine elle-même, exécuter un routeur de sous-réseau Tailscale sur un VPS couvre l’approbation des routes, l’IP forwarding et le flag côté client que TS_ROUTES ne configure pas seul.
Si Tailscale sert à vous fournir une URL d’administration plutôt qu’une route, tailscale serve et tailscale funnel placent HTTPS devant gluetun:8080 pour votre tailnet. Seul funnel l’expose à l’Internet public.
Un effet secondaire est visible lorsque Tailscale s’exécute à l’intérieur du tunnel. Ses pairs voient l’adresse du fournisseur VPN ; il faut donc s’attendre à un recours plus fréquent aux relays. tailscale status affiche relay "..." à côté d’un pair au lieu de direct dans ce cas. La connexion fonctionne, mais elle est plus lente. Si le réseau overlay est tout ce dont vous avez réellement besoin, la différence entre WireGuard classique et Tailscale constitue un meilleur point de départ.
Ce qui ne fonctionne pas et le message affiché
Docker refuse de créer le conteneur de l’application. Error response from daemon: conflicting options: port publishing and the container type network mode signifie qu’un bloc ports: est encore associé au service. Déplacez-le vers gluetun.
Compose refuse l’ensemble du fichier. Un service ne peut pas définir à la fois network_mode et networks. Déclarez les réseaux sur gluetun.
Un autre conteneur ne peut pas résoudre le nom de l’application. curl: (6) Could not resolve host: qbittorrent est le comportement attendu, car le conteneur associé n’a rejoint aucun réseau et n’a enregistré aucun nom. Utilisez gluetun et le port.
Le deuxième conteneur associé ne démarre pas. Deux processus dans un même namespace ne peuvent pas utiliser le même port. Le processus qui échoue indique que l’adresse est déjà utilisée. Modifiez le port interne de l’application ou exécutez un second gluetun.
L’application n’a plus de réseau après une modification de gluetun. Le redémarrage ou la recréation de gluetun coupe la connectivité de tous les conteneurs qui lui sont associés. Redémarrez ces conteneurs.
Les petites pages se chargent, mais les grandes restent bloquées. Il s’agit d’un problème de MTU (unité de transmission maximale). Le tunnel ajoute une surcharge, et un équipement du chemin abandonne les paquets trop volumineux sans renvoyer d’erreur. Réduisez WIREGUARD_MTU, essayez 1400, puis 1320.
Gluetun ne devient jamais healthy. Le contrôle de démarrage indique les premiers éléments à vérifier : WARN [vpn] restarting VPN because it failed to pass the healthcheck: startup check: dialing: dial tcp4: lookup cloudflare.com: i/o timeout. Vérifiez si la clé a expiré, puis si la liste des serveurs est obsolète, et enfin si le firewall de l’hôte bloque le trafic UDP sortant.
FAQ
Pourquoi les ports publiés de mon conteneur ne fonctionnent-ils plus derrière Gluetun ?
Parce que network_mode: "service:gluetun" place le conteneur dans l’espace de noms réseau de Gluetun. Un espace de noms possède une seule adresse IP et un seul ensemble de ports en écoute. L’application continue d’écouter, mais la règle de publication doit être définie sur le conteneur qui possède cet espace de noms. Déplacez la liste ports: vers le service Gluetun. Si vous l’avez laissée sur le service attaché, Docker ne le créera même pas : Error response from daemon: conflicting options: port publishing and the container type network mode.
Comment accéder à un conteneur situé dans le tunnel VPN depuis un conteneur situé à l’extérieur ?
Utilisez le nom de service de Gluetun et le port sur lequel l’application écoute, par exemple gluetun:8080. Le conteneur attaché n’est connecté à aucun réseau Docker qui lui est propre. Son propre nom ne peut donc jamais être résolu. La publication d’un port n’est pas nécessaire pour le trafic entre conteneurs. Dans l’autre sens, un conteneur situé dans l’espace de noms peut joindre un conteneur externe avec son nom de service, par exemple postgres:5432, avec Gluetun v3.41 et les versions ultérieures. Un client situé sur un autre sous-réseau, comme un ordinateur portable sur votre LAN, est bloqué par le pare-feu de Gluetun jusqu’à ce que vous ajoutiez ce sous-réseau à FIREWALL_OUTBOUND_SUBNETS.
Gluetun fonctionne-t-il comme un kill switch lorsque le VPN tombe ?
Oui, pour deux raisons. Le conteneur attaché ne possède aucune route en dehors de celle de l’espace de noms partagé. Si le tunnel tombe, il n’a donc plus aucun chemin pour sortir de la machine. Le pare-feu de Gluetun autorise également le trafic sortant uniquement via le tunnel et vers le point de terminaison du serveur VPN. Gluetun redémarre ensuite le VPN en interne et journalise WARN [vpn] restarting VPN because it failed to pass the healthcheck au lieu de quitter, car chaque conteneur attaché perd son réseau lorsque Gluetun redémarre lui-même.
Tailscale et Gluetun dans la même stack : lequel achemine le trafic sortant ?
Gluetun, dans toutes les configurations sauf une. Par défaut, Tailscale achemine uniquement le trafic entre les appareils de votre tailnet et laisse le trafic public inchangé. Dans le mode userspace par défaut de l’image du conteneur, il ne crée aucune interface. Il ne peut donc pas modifier le routage. Avec TS_USERSPACE=false, il installe uniquement des routes pour 100.64.0.0/10 et pour les sous-réseaux annoncés. L’exception est un exit node : sudo tailscale set --exit-node=<exit-node-ip> fait de Tailscale la route par défaut, qui devient alors prioritaire. Choisissez un seul produit pour gérer la route par défaut au lieu de superposer les deux.