SSD Nodes Learn Hosting plans →
Guías Matt ConnorPor Matt Connor

Tailscale en Docker Compose sin puertos públicos

El ejemplo oficial deja un puerto público en el host. Usa un sidecar de Tailscale para que la app no publique puertos y solo sea accesible por su nombre en la tailnet.

Ejecutar Tailscale en una pila de Docker Compose

Ejecutar Tailscale en Docker Compose requiere dos servicios. Uno es el contenedor tailscale/tailscale que se conecta a tu tailnet. El otro es el contenedor de la aplicación, que comparte el espacio de nombres de red del primer contenedor en lugar de publicar un puerto en el host. El resultado es un servicio que tu portátil abre por nombre y al que Internet público no puede acceder en absoluto.

Una tailnet es la red privada que Tailscale crea entre los dispositivos con los que inicias sesión. Si este término es nuevo para ti, lee primero qué es Tailscale y cómo conecta dos máquinas. En esta guía se presupone que Docker Engine y el plugin Compose v2 ya funcionan, tal como se explica en una primera pila de Docker Compose en un VPS.

El ejemplo del proveedor y el puerto que deja abierto

La propia guía de Compose de Tailscale publica una pila muy parecida a esta.

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-stopped

Todas las líneas del servicio tailscale son correctas. TS_AUTHKEY autentica el nodo. TS_STATE_DIR indica a tailscaled dónde escribir su estado, y el montaje bind conserva ese estado en el disco. El segundo servicio es el problema.

Esos dos contenedores están en la red bridge predeterminada de Compose, cada uno con su propia dirección. Este es el comportamiento normal explicado en cómo las redes de Compose conectan contenedores por nombre de servicio. El contenedor tailscale se unió al tailnet por sí mismo, pero no reenvía tráfico al contenedor nginx. Por tanto, el tailnet no tiene ninguna ruta hacia nginx y la única forma de acceder desde fuera del VPS es mediante el puerto 8080 del host.

Un puerto publicado se enlaza a 0.0.0.0, salvo que escriba una dirección delante del puerto o establezca otra dirección de enlace predeterminada en la red. Por eso, en un VPS, ese puerto responde en la IP pública. La aplicación aparece en el tailnet por su nombre, pero en la práctica está expuesta a Internet. Un firewall del host tampoco lo evita, porque Docker inserta sus propias reglas de reenvío antes de la cadena de ufw. Esa es la trampa descrita en por qué una regla ufw deny no cierra un puerto publicado por Docker.

Hay otro detalle que conviene señalar. El ejemplo concede net_admin y net_raw, pero nunca asigna /dev/net/tun. TS_USERSPACE tiene el valor predeterminado true, por lo que el contenedor ejecuta la pila de red en espacio de usuario y esas dos capacidades no tienen ningún efecto.

El sidecar: un espacio de nombres, ningún puerto publicado

Coloque la aplicación dentro del espacio de nombres de red del contenedor de tailscale con network_mode: service:tailscale. Ambos procesos verán el mismo loopback y la misma dirección de tailnet, aunque se ejecuten en contenedores separados.

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-stopped

La clave debe ir en un archivo .env junto al archivo compose, no en el YAML. Así, el archivo que confirme en el repositorio no contendrá ningún secreto. Una línea: TS_AUTHKEY=tskey-auth-.... Mantener los secretos fuera de un archivo compose confirmado en el repositorio explica el resto de este patrón.

Inícielo y compruebe ambas partes.

docker compose up -d
docker compose exec tailscale tailscale status
docker compose logs --tail 20 tailscale

tailscale status debe mostrar una línea para este nodo con una dirección 100.x, seguida de las demás máquinas de su tailnet. Desde un portátil que también haya iniciado sesión, curl http://nginx-demo/ debe devolver la página de bienvenida de nginx. En el propio VPS, sudo ss -lntp | grep 8080 no devuelve nada porque no se publicó ningún puerto.

Por qué el puerto 80 y no 8080: en modo userspace, tailscaled envía una conexión entrante del túnel al mismo puerto de localhost. nginx escucha en el puerto 80 dentro del espacio de nombres compartido, por lo que la tailnet puede acceder a él mediante el puerto 80. Si cambia el puerto en el que escucha la aplicación, el puerto de la tailnet también cambia. Este método de compartir el espacio de nombres no es específico de Tailscale. Las mismas preguntas sobre cómo acceder al host y al resto de la pila aparecen en un contenedor de Gluetun que controla la red de sus contenedores vecinos.

¿Qué clave de autenticación necesita el contenedor?

El tipo de clave determina qué ocurre en el segundo arranque, así que elíjalo antes de desplegar. Genere una en la página Keys de la consola de administración. El cuadro de diálogo la muestra una sola vez.

  • Las claves de un solo uso autentican un único dispositivo. Una stack recreada sin su directorio de estado no volverá a iniciarse.
  • Las claves reutilizables autentican cualquier número de dispositivos. Esto es lo que suele necesitar una stack de Compose.
  • Las claves efímeras marcan el nodo para su limpieza automática. Tailscale elimina un dispositivo efímero entre 30 y 60 minutos después de su última actividad.
  • Las claves preaprobadas omiten la aprobación manual del dispositivo. Esto sólo importa si la aprobación de dispositivos está activada para su tailnet.
  • Las claves etiquetadas aplican una etiqueta ACL como tag:container durante la autenticación. El dispositivo deja de pertenecer a una persona y la caducidad de su clave se desactiva de forma predeterminada.

La última línea es la relevante para la operación. La clave de un nodo caduca después de 180 días de forma predeterminada. Cuando caduca, el nodo deja de estar en la tailnet hasta que una persona vuelve a iniciar sesión con él o un administrador desactiva la caducidad de su clave. Una clave etiquetada elimina ese límite, por eso las etiquetas existen para servidores y contenedores.

La caducidad de una clave de autenticación es un evento distinto. Es habitual confundir ambos conceptos. Las claves de autenticación duran entre 1 y 90 días, y 90 es el valor predeterminado. Cuando una clave alcanza su fecha de caducidad, no desconecta los dispositivos que ya autenticó. Sólo impide añadir dispositivos nuevos. Para un servicio de larga duración, use una clave reutilizable, etiquetada y no efímera. Para una stack que destruye y recrea constantemente, como un entorno de vista previa, una clave efímera mantiene limpia la consola de administración sin necesidad de eliminar dispositivos manualmente.

¿Por qué el contenedor vuelve como una máquina nueva?

Porque tailscaled conservó su estado en una ubicación que no persistió. Si TS_STATE_DIR no está definido, la imagen conserva el estado sólo en memoria. Si está definido pero no está montado, el estado termina en la capa escribible del contenedor y docker compose down elimina el contenedor.

La identidad del nodo se encuentra en ese directorio de estado. Persístalo y el contenedor conservará su nombre y su dirección 100.x entre reinicios, junto con cualquier configuración de servicio. Si lo pierde, el siguiente arranque será un primer arranque: el contenedor se autentica de nuevo con la misma clave y la consola de administración obtiene una segunda máquina. Ambas anuncian el nombre de host nginx-demo, por lo que MagicDNS asigna un sufijo numérico a la más reciente y todos los enlaces guardados apuntan al nodo inactivo.

Deben cumplirse dos condiciones. TS_STATE_DIR=/var/lib/tailscale debe estar definido, porque fuera de Kubernetes no tiene un valor predeterminado. Además, esa ruta debe estar montada, ya sea con el montaje bind anterior o con un volumen con nombre. Esta elección se explica en montajes bind frente a volúmenes con nombre. Definir uno sin el otro es el error más frecuente. Un montaje sin TS_STATE_DIR pierde la identidad en cada reinicio, y un TS_STATE_DIR sin montaje falla silenciosamente: la pila funciona correctamente hasta el primer down.

Verifíquelo en lugar de darlo por hecho.

docker compose down
ls -l ./ts-state
docker compose up -d
docker compose exec tailscale tailscale status

./ts-state ya debería contener tailscaled.state antes del segundo up, y el nodo debería volver con la dirección que tenía antes. Una dirección diferente indica que el montaje no está funcionando correctamente.

Fije la imagen y anote la etiqueta utilizada

tailscale/tailscale:latest sigue la compilación estable más reciente. Un docker compose pull dentro de seis meses descargará un tailscaled diferente y el siguiente docker compose up -d volverá a crear el contenedor con código que usted nunca eligió. Un reinicio normal conserva la imagen anterior, por lo que el cambio se aplica cuando se vuelva a crear el contenedor, no cuando se descargó la imagen. La configuración anterior fija v1.102.5, la imagen a la que apuntaban las etiquetas stable y latest de Docker Hub el 10 de octubre de 2026. El registro de cambios de Tailscale ya incluye el cliente 1.104.1, publicado el 7 de octubre de 2026, así que compruebe en Docker Hub que exista una etiqueta de imagen coincidente antes de cambiar la fijación. Docker Hub también publica v1.102 para la línea de parches y una etiqueta unstable, que no debe utilizar en un servidor.

Actualice de forma controlada.

docker compose pull tailscale
docker compose up -d
docker compose exec tailscale tailscale version

Editar la etiqueta y ejecutar up -d vuelve a crear el contenedor. No es lo mismo que reiniciarlo. Conviene leer la diferencia entre restart, up y rebuild antes de investigar un cambio de versión que no se haya aplicado.

Red en userspace y su coste

TS_USERSPACE tiene el valor predeterminado true. El contenedor ejecuta entonces una pila TCP/IP en userspace y nunca accede a /dev/net/tun. Esto permite que funcione en hosts que no entregan el dispositivo TUN a un contenedor. El tráfico entrante sigue funcionando porque las conexiones entrantes del túnel se reenvían al mismo puerto en localhost. Por eso el sidecar anterior no necesita ningún dispositivo ni ninguna capability.

El coste aparece en el tráfico saliente. En modo userspace, la aplicación no puede abrir directamente un socket hacia otro nodo de tailnet. En su lugar, tailscaled ofrece un proxy SOCKS5 y un proxy HTTP. Por tanto, debe configurar TS_SOCKS5_SERVER=localhost:1055 en el servicio de tailscale y ALL_PROXY=socks5://localhost:1055 en la aplicación, y la aplicación debe respetarlo. Cualquier aplicación que ignore las variables de entorno del proxy no podrá acceder a la tailnet.

La propia pila tiene límites que debe conocer. Sólo transporta TCP y UDP, por lo que otros protocolos IP, como SCTP, no pasan. ICMP se limita a ping, que el daemon reconstruye, y esto añade una pequeña latencia aparente. Las conexiones terminan en el nodo y se vuelven a establecer hacia el destino, por lo que no son de extremo a extremo. Desde la versión 1.20, un nodo userspace puede acceder a un exit node o a una ruta de subred anunciada por otro nodo, pero sólo mediante esos proxies. También puede anunciarlos por sí mismo.

Cuando necesite tráfico saliente transparente, cambie a la red del kernel añadiendo tres elementos al servicio de tailscale.

    environment:
      - TS_USERSPACE=false
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - net_admin

Compruebe primero que el host pueda proporcionarlo con test -c /dev/net/tun && echo ok. En KVM, el dispositivo está disponible. En la virtualización de contenedores que comparte el kernel del host, puede no estar disponible, y userspace será entonces la única opción. Entregue el dispositivo TUN al contenedor si debe ser un router de subred que anuncie un rango privado o un exit node para sus otros dispositivos, porque esos roles son los que más limitaciones sufren en userspace.

Acceder al servicio: publicarlo o usar el nombre MagicDNS simple

La opción más sencilla es el nombre de MagicDNS. Desde cualquier dispositivo de la tailnet, http://nginx-demo/ funciona, al igual que el nombre completo http://nginx-demo.your-tailnet.ts.net/. El tráfico HTTP simple no viaja en texto claro, porque WireGuard cifra el tráfico entre los dos nodos. Qué puede ver y qué no puede ver el servidor de coordinación delimita dónde termina esa garantía. No hay ningún certificado, por lo que el navegador marca el origen como no seguro y cualquier función web que requiera un contexto seguro no se ejecuta.

La otra opción es Tailscale Serve, que se ejecuta dentro del contenedor.

docker compose exec tailscale tailscale serve --bg localhost:80
docker compose exec tailscale tailscale serve status

Esto publica la aplicación en https://nginx-demo.your-tailnet.ts.net con un certificado que aprovisiona Tailscale. MagicDNS y los certificados HTTPS deben estar habilitados en la página DNS de la consola de administración. De lo contrario, no hay ningún nombre que incluir en un certificado. --bg escribe la configuración en el estado de tailscaled que guardó, por lo que se recupera junto con el contenedor, y tailscale serve reset la elimina. TS_SERVE_CONFIG apunta a un archivo JSON si prefiere mantener esa configuración en el repositorio en lugar de incluirla en un comando de shell. Serve permanece dentro de la tailnet. Funnel es el comando independiente que publica el mismo servicio en Internet, así que lea la diferencia entre Serve y Funnel antes de ejecutar cualquiera de los dos.

Modos de fallo y mensajes que verá

Error response from daemon: conflicting options: port publishing and the container type network mode. Ha dejado un bloque ports: en el servicio sidecar. Sólo el contenedor propietario del namespace puede publicar puertos, y una aplicación que sólo debe estar disponible en tailnet no debe publicar ninguno. Elimine el bloque.

El contenedor de la aplicación está en ejecución y no recibe ninguna conexión. Ha recreado el servicio tailscale por separado. El namespace que poseía se destruyó con él, y la aplicación está conectada a algo que ya no existe. Recree ambos servicios juntos con docker compose up -d --force-recreate.

No aparece ningún nodo en la consola de administración. Lea docker compose logs tailscale. Allí se informa de las claves rechazadas. Una clave de un solo uso que ya se ha utilizado y una clave cuya fecha de caducidad ya ha pasado detienen el nodo antes de que llegue a tailnet.

El nodo está activo, tailscale status parece correcto y curl http://nginx-demo/ se queda esperando. La aplicación no está escuchando donde cree. Compruébelo desde dentro del namespace compartido con docker compose exec nginx wget -qO- http://localhost/. Si también falla, el problema está en la aplicación, no en Tailscale. Si funciona, la aplicación está vinculada a una interfaz en lugar de a todas.

La máquina desapareció de la consola una hora después de detener el stack. La clave era efímera. La eliminación se produce entre 30 y 60 minutos después de la última actividad. Ese es el comportamiento previsto.

A partir de la versión 1.78, la imagen puede exponer un endpoint /healthz sin autenticación: establezca TS_ENABLE_HEALTH_CHECK=true, que escucha en TS_LOCAL_ADDR_PORT, cuyo valor predeterminado es [::]:9002. Apunte un healthcheck de Compose a él para que un nodo que no pueda autenticarse se informe como no saludable, en lugar de permanecer aparentemente correcto. Si prefiere no depender en absoluto de los servidores de coordinación de Tailscale, el mismo archivo Compose se comunica con su propio plano de control mediante TS_EXTRA_ARGS=--login-server=https://headscale.example.com. Ese es el punto de partida para ejecutar Headscale como su propio servidor de control.

FAQ

¿Por qué otros dispositivos de mi tailnet no pueden acceder al contenedor de mi aplicación?

Porque el contenedor de Tailscale se unió a la tailnet únicamente para sí mismo. Si la aplicación se ejecuta en la red bridge predeterminada de Compose con su propia dirección, el nodo de Tailscale no reenvía tráfico hacia ella y la única forma de acceder desde fuera del host es mediante el puerto del host publicado. Asigne a la aplicación network_mode: service:tailscale para que comparta el espacio de nombres de red del contenedor de Tailscale y elimine su bloque ports:. La aplicación estará disponible en la tailnet mediante el puerto en el que escucha.

¿Necesito /dev/net/tun para ejecutar Tailscale en Docker Compose?

No para el acceso entrante. TS_USERSPACE tiene el valor predeterminado true y, en ese modo, tailscaled ejecuta su propia pila de red y reenvía las conexiones entrantes del túnel al mismo puerto en localhost. Por tanto, un sidecar funciona sin dispositivo y sin capacidades adicionales. Necesita /dev/net/tun, TS_USERSPACE=false y net_admin cuando el contenedor debe abrir conexiones salientes a la tailnet de forma transparente, actuar como router de subred o funcionar como nodo de salida.

¿Debo usar una clave de autenticación efímera o reutilizable para una pila de Compose?

Use una clave reutilizable, etiquetada y no efímera, para todo lo que tenga una ejecución prolongada. Un dispositivo que se autentica con una clave etiquetada tiene deshabilitada de forma predeterminada la caducidad de su clave de nodo. Por tanto, el contenedor no abandona la tailnet después de 180 días mientras espera a que alguien vuelva a iniciar sesión en él. Añadir una etiqueta a un nodo que ya ha iniciado sesión no cambia su caducidad hasta que vuelve a autenticarse. Elija una clave efímera sólo para pilas que destruya con frecuencia, como los entornos de vista previa, porque Tailscale elimina un dispositivo efímero entre 30 y 60 minutos después de su última actividad y la consola de administración permanece limpia.

¿Por qué mi contenedor aparece como una máquina nueva cada vez que se reinicia?

El directorio de estado no se está conservando, por lo que tailscaled se inicia sin identidad y se autentica como un nodo completamente nuevo. Establezca TS_STATE_DIR=/var/lib/tailscale, que no tiene un valor predeterminado fuera de Kubernetes, y monte esa ruta en un bind mount o en un volumen con nombre. Sin TS_STATE_DIR, el estado se mantiene en memoria y se pierde en cada reinicio. Si lo establece sin un montaje, todo parece funcionar hasta el primer docker compose down. Confirme que tailscaled.state exista en el directorio montado mientras la pila esté detenida.