Tailscale avec Docker Compose sans port public
L’exemple Compose de Tailscale expose un port sur l’hôte. Utilisez un sidecar Tailscale pour supprimer ce port et accéder à l’application par son nom tailnet.
Exécuter Tailscale dans une stack Docker Compose
Exécuter Tailscale dans Docker Compose nécessite deux services. Le premier est le conteneur tailscale/tailscale, qui rejoint votre tailnet. L’autre est votre conteneur d’application, qui partage l’espace de noms réseau du premier conteneur au lieu de publier un port sur l’hôte. Votre service devient ainsi accessible par son nom depuis votre ordinateur portable, sans être joignable depuis Internet public.
Un tailnet est le réseau privé que Tailscale établit entre les appareils auxquels vous vous connectez. Si ce terme vous est inconnu, lisez d’abord ce qu’est Tailscale et comment il connecte deux machines. Ce guide suppose que Docker Engine et le plugin Compose v2 fonctionnent déjà, comme dans une première stack Docker Compose sur un VPS.
L’exemple du fournisseur et le port qu’il laisse ouvert
Le guide Compose de Tailscale publie une stack proche de celle-ci.
services:
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale
hostname: tailscale-nginx
environment:
- TS_AUTHKEY=tskey-auth-REPLACE-ME
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- ./tailscale-state:/var/lib/tailscale
cap_add:
- net_admin
- net_raw
restart: unless-stopped
nginx:
image: nginx:latest
container_name: nginx_server
ports:
- "8080:80"
depends_on:
- tailscale
restart: unless-stoppedChaque ligne du service tailscale est correcte. TS_AUTHKEY authentifie le nœud. TS_STATE_DIR indique à tailscaled où écrire son état, et le bind mount conserve cet état sur le disque. Le problème vient du deuxième service.
Les deux conteneurs se trouvent sur le réseau bridge Compose par défaut, chacun avec sa propre adresse. C’est le comportement habituel expliqué dans comment les réseaux Compose connectent les conteneurs par nom de service. Le conteneur tailscale a rejoint le tailnet pour lui-même, mais il ne transmet aucun trafic vers le conteneur nginx. Le tailnet n’a donc aucune route vers nginx, et le seul accès depuis l’extérieur du VPS passe par le port 8080 de l’hôte.
Un port publié est lié à 0.0.0.0, sauf si vous indiquez une adresse devant le port ou définissez une autre adresse de binding par défaut sur le réseau. Sur un VPS, ce port répond donc sur l’adresse IP publique. L’application est rattachée au tailnet en théorie, mais exposée sur Internet en pratique. Un firewall sur l’hôte ne suffit pas non plus, car Docker insère ses propres règles de forwarding avant la chaîne d’ufw. C’est le piège décrit dans pourquoi une règle ufw deny ne ferme pas un port Docker publié.
Un dernier détail mérite d’être signalé. L’exemple accorde net_admin et net_raw, mais ne mappe jamais /dev/net/tun. TS_USERSPACE vaut true par défaut. Le conteneur utilise donc la pile réseau en userspace, et ces deux capabilities ne servent à rien.
Le sidecar : un espace de noms partagé, aucun port publié
Placez l’application dans l’espace de noms réseau du conteneur Tailscale avec network_mode: service:tailscale. Les deux processus voient alors la même interface loopback et la même adresse tailnet, même s’ils s’exécutent dans des conteneurs distincts.
services:
tailscale:
image: tailscale/tailscale:v1.102.5
container_name: ts-nginx
hostname: nginx-demo
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_HOSTNAME=nginx-demo
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- ./ts-state:/var/lib/tailscale
restart: unless-stopped
nginx:
image: nginx:1.30.4-alpine
network_mode: service:tailscale
depends_on:
- tailscale
restart: unless-stoppedLa clé doit être placée dans un fichier .env à côté du fichier Compose, et non dans le YAML. Le fichier que vous commitez ne contient ainsi aucun secret. Une seule ligne suffit : TS_AUTHKEY=tskey-auth-.... Ne pas inclure les secrets dans un fichier Compose commité présente le reste de cette méthode.
Démarrez les conteneurs et vérifiez les deux composants.
docker compose up -d
docker compose exec tailscale tailscale status
docker compose logs --tail 20 tailscaletailscale status doit afficher une ligne pour ce nœud avec une adresse 100.x, suivie des autres machines de votre tailnet. Depuis un ordinateur portable également connecté, curl http://nginx-demo/ renvoie la page d’accueil de nginx. Sur le VPS lui-même, sudo ss -lntp | grep 8080 ne renvoie rien, car aucun port n’a été publié.
Pourquoi utiliser le port 80 plutôt que 8080 ? En mode userspace, tailscaled transmet une connexion entrante du tunnel au même port sur localhost. nginx écoute sur le port 80 dans l’espace de noms partagé, donc le tailnet y accède sur le port 80. Si vous modifiez le port d’écoute de l’application, le port utilisé par le tailnet change également. Cette méthode de partage d’espace de noms ne concerne pas uniquement Tailscale. Les mêmes questions sur l’accès à l’hôte et au reste de la stack se posent avec un conteneur Gluetun qui gère le réseau de ses conteneurs voisins.
Quelle clé d’authentification le conteneur doit-il utiliser ?
Le type de clé détermine ce qui se passe au deuxième démarrage. Choisissez-le avant le déploiement. Générez une clé depuis la page Keys de la console d’administration. La boîte de dialogue ne l’affiche qu’une seule fois.
- Les clés à usage unique authentifient un seul appareil. Une stack recréée sans son répertoire d’état ne redémarrera pas correctement.
- Les clés réutilisables authentifient un nombre quelconque d’appareils. C’est généralement ce qu’il faut pour une stack Compose.
- Les clés éphémères marquent le nœud pour un nettoyage automatique. Tailscale supprime un appareil éphémère 30 à 60 minutes après sa dernière activité.
- Les clés préapprouvées évitent l’approbation manuelle de l’appareil. Cela ne sert que si l’approbation des appareils est activée pour votre tailnet.
- Les clés marquées appliquent un tag ACL tel que
tag:containerau moment de l’authentification. L’appareil n’appartient alors plus à une personne et l’expiration de sa clé est désactivée par défaut.
Cette dernière ligne est celle qui compte en exploitation. Une clé de nœud expire après 180 jours par défaut. Une fois expiré, le nœud disparaît du tailnet jusqu’à ce qu’une personne le reconnecte ou qu’un administrateur désactive l’expiration de sa clé. Une clé marquée supprime cette échéance. C’est pourquoi les tags existent pour les serveurs et les conteneurs.
L’expiration d’une clé d’authentification est un événement distinct. Ces deux mécanismes sont souvent confondus. Les clés d’authentification durent de 1 à 90 jours, avec 90 jours par défaut. Lorsqu’une clé atteint sa date d’expiration, les appareils qu’elle a déjà authentifiés restent connectés. Elle empêche seulement d’en ajouter de nouveaux. Pour un service qui doit rester disponible longtemps, utilisez une clé réutilisable, marquée et non éphémère. Pour une stack que vous supprimez et recréez souvent, par exemple un environnement de prévisualisation, une clé éphémère permet de garder la console d’administration propre sans suppression manuelle.
Pourquoi le conteneur revient-il comme une nouvelle machine ?
Parce que tailscaled a conservé son état à un emplacement qui n’a pas persisté. Si TS_STATE_DIR n’est pas défini, l’image conserve l’état uniquement en mémoire. S’il est défini mais non monté, l’état est écrit dans la couche inscriptible du conteneur, puis docker compose down supprime le conteneur.
L’identité du nœud se trouve dans ce répertoire d’état. Rendez-le persistant pour que le conteneur conserve son nom et son adresse 100.x après les redémarrages, ainsi que sa configuration de serveur. Si vous le perdez, le démarrage suivant est un premier démarrage : le conteneur s’authentifie de nouveau avec la même clé, et la console d’administration ajoute une deuxième machine. Les deux revendiquent le nom d’hôte nginx-demo. MagicDNS attribue donc un suffixe numérique à la plus récente, et tous les liens que vous avez enregistrés pointent vers l’ancien nœud, qui n’est plus actif.
Deux conditions doivent être réunies. TS_STATE_DIR=/var/lib/tailscale doit être défini, car il n’a aucune valeur par défaut en dehors de Kubernetes. Ce chemin doit également être monté, avec le bind mount indiqué plus haut ou avec un named volume. Ce choix est présenté dans bind mounts ou named volumes. Définir l’un sans l’autre est l’erreur la plus courante. Un montage sans TS_STATE_DIR perd l’identité à chaque redémarrage, tandis qu’un TS_STATE_DIR sans montage échoue silencieusement : la stack fonctionne parfaitement jusqu’au premier down.
Vérifiez-le au lieu de le supposer.
docker compose down
ls -l ./ts-state
docker compose up -d
docker compose exec tailscale tailscale status./ts-state doit déjà contenir tailscaled.state avant le deuxième up, et le nœud doit revenir avec l’adresse qu’il avait auparavant. Une adresse différente signifie que le montage ne fonctionne pas correctement.
Figez l’image et notez le tag utilisé
tailscale/tailscale:latest suit la dernière version stable. Un docker compose pull dans six mois récupérera un autre tailscaled, et le docker compose up -d suivant recréera le conteneur avec du code que vous n’avez jamais choisi. Un simple redémarrage conserve l’ancienne image. La modification sera donc appliquée lorsque le conteneur sera recréé, et non lorsque vous aurez exécuté le pull. La stack ci-dessus fige v1.102.5, l’image vers laquelle les tags stable et latest de Docker Hub pointaient le 10 octobre 2026. Le changelog de Tailscale répertorie déjà le client 1.104.1, publié le 7 octobre 2026. Vérifiez donc sur Docker Hub la présence d’un tag d’image correspondant avant de modifier le pin. Docker Hub publie également v1.102 pour la ligne de correctifs, ainsi qu’un tag unstable, à éviter sur un serveur.
Mettez à niveau volontairement.
docker compose pull tailscale
docker compose up -d
docker compose exec tailscale tailscale versionModifier le tag et exécuter up -d recrée le conteneur. Ce n’est pas la même chose que le redémarrer. La différence entre restart, up et rebuild mérite d’être lue avant de diagnostiquer un changement de version qui n’a pas été appliqué.
Le réseau en espace utilisateur et ses coûts
TS_USERSPACE vaut true par défaut. Le conteneur exécute alors une pile TCP/IP en espace utilisateur et n’accède jamais à /dev/net/tun. Cela permet son fonctionnement sur les hôtes qui ne transmettent pas le périphérique TUN à un conteneur. Les connexions entrantes fonctionnent toujours, car les connexions de tunnel entrantes sont redirigées vers le même port sur localhost. C’est la raison pour laquelle le sidecar présenté plus haut n’a besoin ni de périphérique ni de capabilities.
C’est pour le trafic sortant que cette configuration a un coût. En mode espace utilisateur, l’application ne peut pas simplement ouvrir une socket vers un autre nœud tailnet. tailscaled fournit à la place un proxy SOCKS5 et un proxy HTTP. Vous définissez donc TS_SOCKS5_SERVER=localhost:1055 sur le service tailscale et ALL_PROXY=socks5://localhost:1055 sur l’application, qui doit respecter ce paramètre. Les applications qui ignorent les variables d’environnement des proxys n’atteindront pas le tailnet.
La pile présente certaines limites à connaître. Seuls TCP et UDP sont transportés. Les autres protocoles IP, comme SCTP, ne passent pas. ICMP est limité au ping, que le daemon reconstruit, ce qui ajoute une légère latence apparente. Les connexions sont terminées sur le nœud, puis une nouvelle connexion est établie vers la cible. Elles ne sont donc pas de bout en bout. Depuis la version 1.20, un nœud en espace utilisateur peut atteindre un exit node ou une route de sous-réseau annoncée par un autre nœud, mais uniquement via ces proxys. Il peut également annoncer lui-même ces routes.
Lorsque vous avez besoin d’un trafic sortant transparent, activez le réseau du noyau en ajoutant trois éléments au service tailscale.
environment:
- TS_USERSPACE=false
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- net_adminVérifiez d’abord que l’hôte peut le fournir avec test -c /dev/net/tun && echo ok. Sur KVM, le périphérique est présent. Dans une virtualisation par conteneurs qui partage le noyau de l’hôte, il peut être absent. Dans ce cas, l’espace utilisateur est votre seule solution. Donnez le périphérique TUN au conteneur s’il doit être un subnet router qui annonce une plage privée ou un exit node pour vos autres appareils, car ce sont les rôles les plus limités en espace utilisateur.
Accéder au service : le publier ou utiliser le nom MagicDNS simple
La méthode la plus simple consiste à utiliser le nom MagicDNS. Depuis n’importe quel appareil du tailnet, http://nginx-demo/ fonctionne, tout comme le nom complet http://nginx-demo.your-tailnet.ts.net/. HTTP n’est pas transmis en clair ici, car WireGuard chiffre le trafic entre les deux nœuds. Ce que le serveur de coordination peut voir ou non précise toutefois où cette garantie s’arrête. Aucun certificat n’est utilisé. Le navigateur marque donc l’origine comme non sécurisée, et toute fonctionnalité web qui exige un contexte sécurisé refuse de fonctionner.
L’autre méthode consiste à utiliser Tailscale Serve, exécuté dans le conteneur.
docker compose exec tailscale tailscale serve --bg localhost:80
docker compose exec tailscale tailscale serve statusCette commande publie l’application à l’adresse https://nginx-demo.your-tailnet.ts.net avec un certificat fourni par Tailscale. MagicDNS et les certificats HTTPS doivent tous deux être activés sur la page DNS de la console d’administration. Sinon, aucun nom ne peut être associé à un certificat. --bg écrit la configuration dans l’état de tailscaled que vous avez conservé. Elle est donc restaurée avec le conteneur. tailscale serve reset la supprime. TS_SERVE_CONFIG référence un fichier JSON si vous préférez conserver cette configuration dans le dépôt plutôt que dans une commande shell. Serve reste à l’intérieur du tailnet. Funnel est la commande distincte qui expose le même service sur Internet. Consultez donc la différence entre Serve et Funnel avant d’utiliser l’une ou l’autre.
Modes d’échec et messages affichés
Error response from daemon: conflicting options: port publishing and the container type network mode. Vous avez laissé un bloc ports: sur le service sidecar. Seul le conteneur qui possède le namespace peut publier des ports, et une application accessible uniquement via le tailnet ne doit en publier aucun. Supprimez ce bloc.
Le conteneur de l’application fonctionne, mais aucune requête ne l’atteint. Vous avez recréé le service Tailscale seul. Le namespace qu’il possédait a été supprimé avec lui, et l’application est attachée à une ressource qui n’existe plus. Recréez les deux services ensemble avec docker compose up -d --force-recreate.
Aucun nœud n’apparaît dans la console d’administration. Consultez docker compose logs tailscale. Une clé rejetée y est signalée. Une clé à usage unique déjà utilisée et une clé arrivée à expiration empêchent toutes deux le nœud de rejoindre le tailnet.
Le nœud est actif, tailscale status semble correct, mais curl http://nginx-demo/ reste bloqué. L’application n’écoute pas là où vous le pensez. Interrogez-la depuis le namespace partagé avec docker compose exec nginx wget -qO- http://localhost/. Si cette commande échoue également, le problème vient de l’application, pas de Tailscale. Si elle réussit, l’application est liée à une seule interface au lieu de toutes les interfaces.
La machine a disparu de la console une heure après l’arrêt de la stack. La clé était éphémère. La suppression intervient 30 à 60 minutes après la dernière activité. C’est le fonctionnement attendu.
À partir de la version 1.78, l’image peut exposer un endpoint /healthz sans authentification : définissez TS_ENABLE_HEALTH_CHECK=true. Cet endpoint écoute sur TS_LOCAL_ADDR_PORT, avec [::]:9002 par défaut. Dirigez un healthcheck Compose vers cet endpoint afin qu’un nœud qui n’arrive pas à s’authentifier soit signalé comme non sain au lieu de sembler fonctionner normalement. Si vous préférez ne dépendre d’aucun serveur de coordination Tailscale, le même fichier Compose communique avec votre propre control plane via TS_EXTRA_ARGS=--login-server=https://headscale.example.com. C’est le point de départ pour exécuter Headscale comme votre propre serveur de contrôle.
FAQ
Pourquoi les autres appareils de mon tailnet ne peuvent-ils pas joindre mon conteneur d’application ?
Parce que le conteneur Tailscale a rejoint le tailnet pour lui-même uniquement. Si l’application s’exécute sur le réseau bridge Compose par défaut avec sa propre adresse, le nœud Tailscale ne lui transmet rien. Depuis l’extérieur de l’hôte, le seul accès possible passe alors par le port publié de l’hôte. Donnez à l’application network_mode: service:tailscale afin qu’elle partage l’espace de noms réseau du conteneur Tailscale, puis supprimez son bloc ports:. L’application est alors accessible sur le tailnet via le port sur lequel elle écoute.
Ai-je besoin de /dev/net/tun pour exécuter Tailscale dans Docker Compose ?
Pas pour les accès entrants. TS_USERSPACE vaut true par défaut. Dans ce mode, tailscaled utilise sa propre pile réseau et transmet les connexions entrantes du tunnel vers le même port sur localhost. Un sidecar fonctionne donc sans périphérique ni capability supplémentaire. Vous avez besoin de /dev/net/tun, TS_USERSPACE=false et net_admin lorsque le conteneur doit ouvrir de manière transparente des connexions sortantes vers le tailnet, ou fonctionner comme subnet router ou exit node.
Dois-je utiliser une clé d’authentification éphémère ou réutilisable pour une stack Compose ?
Utilisez une clé réutilisable, avec un tag et non éphémère, pour tout ce qui doit rester actif longtemps. Un appareil qui s’authentifie avec une clé comportant un tag a l’expiration de sa node key désactivée par défaut. Le conteneur ne disparaît donc pas du tailnet après 180 days en attendant qu’un administrateur le réauthentifie. Ajouter un tag à un nœud déjà authentifié ne modifie pas son expiration tant qu’il ne s’authentifie pas de nouveau. Choisissez une clé éphémère uniquement pour les stacks que vous détruisez souvent, par exemple les environnements de preview. Tailscale supprime un appareil éphémère 30 à 60 minutes après sa dernière activité, ce qui permet de garder la console d’administration propre.
Pourquoi mon conteneur apparaît-il comme une nouvelle machine à chaque redémarrage ?
Le répertoire d’état n’est pas persisté. tailscaled démarre donc sans identité et s’authentifie comme un tout nouveau nœud. Définissez TS_STATE_DIR=/var/lib/tailscale, qui n’a aucune valeur par défaut en dehors de Kubernetes, puis montez ce chemin vers un bind mount ou un volume nommé. Sans TS_STATE_DIR, l’état reste en mémoire et est perdu à chaque redémarrage. Définissez-le sans montage et tout semble fonctionner jusqu’au premier docker compose down. Vérifiez que tailscaled.state existe dans le répertoire monté lorsque la stack est arrêtée.