SSD Nodes Learn Hosting plans →
Guías Matt ConnorPor Matt Connor · Actualizado 2026-10-03

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 guía de Compose de Tailscale publica una pila muy similar 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

Cada línea del servicio tailscale es correcta. 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 que se explica 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 nada al contenedor nginx. Por tanto, la única ruta hacia nginx es el puerto 8080 del host.

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

Hay otro detalle que conviene mencionar. El ejemplo concede net_admin y net_raw, pero nunca asigna /dev/net/tun. TS_USERSPACE tiene el valor true de forma predeterminada, 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 en el espacio de nombres de red del contenedor de tailscale con network_mode: service:tailscale. Ambos procesos verán entonces el mismo loopback y la misma dirección de tailnet, aunque se ejecuten en contenedores separados.

services:
  tailscale:
    image: tailscale/tailscale:v1.102.3
    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 estar en un archivo .env junto al archivo compose, no en el YAML, para que el archivo que confirme en el repositorio no contenga ningún secreto. Una línea: TS_AUTHKEY=tskey-auth-.... Cómo mantener los secretos fuera de un archivo compose confirmado explica el resto de este patrón.

Inicie los contenedores y compruebe ambas partes.

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

tailscale status debería 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/ devuelve la página de bienvenida de nginx. En el propio VPS, sudo ss -lntp | grep 8080 no devuelve nada porque no se ha publicado ningún puerto.

Por qué el puerto 80 y no 8080: en el 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 se puede acceder a él desde la tailnet mediante el puerto 80. Si cambia el puerto en el que escucha la aplicación, el puerto de la tailnet cambia con él. Este mecanismo de uso compartido del espacio de nombres no es específico de Tailscale. Las mismas preguntas sobre cómo acceder al host y al resto del stack 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 inicio, 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 pila recreada sin su directorio de estado no volverá a iniciarse.
  • Las claves reutilizables autentican cualquier número de dispositivos. Es lo que suele necesitar una pila 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 con etiquetas 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.

Esa última línea es la importante desde el punto de vista operativo. Una clave de nodo caduca después de 180 días de forma predeterminada. Cuando caduca, el nodo desaparece de la tailnet hasta que una persona vuelva a iniciar sesión con él. Una clave con etiqueta elimina ese aviso, por eso las etiquetas existen para servidores y contenedores.

La caducidad de una clave de autenticación es un evento distinto, y es habitual confundir ambos conceptos. Las claves de autenticación duran entre 1 y 90 días, con 90 como 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, con etiqueta y no efímera. Para una pila que desmantela 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 escribió su estado en la capa modificable del contenedor y docker compose down eliminó el contenedor.

La identidad del nodo se almacena en ese directorio de estado. Persístalo y el contenedor conservará su nombre y su dirección 100.x después de los reinicios, junto con cualquier configuración del servicio. Si lo pierde, el siguiente inicio será un primer inicio: el contenedor volverá a autenticarse con la misma clave y la consola de administración mostrará una segunda máquina. Ambas anunciarán el nombre de host nginx-demo, por lo que MagicDNS añadirá un sufijo numérico al nodo más reciente, y todos los enlaces guardados apuntarán al nodo eliminado.

Deben cumplirse dos condiciones. TS_STATE_DIR=/var/lib/tailscale debe estar definido porque no tiene un valor predeterminado fuera de Kubernetes. Además, esa ruta debe estar montada mediante el bind mount anterior o un volumen con nombre. Esta elección se explica en bind mounts frente a volúmenes con nombre. Definir una condición sin la otra es el error más habitual y falla de forma silenciosa: el stack funciona correctamente hasta el primer down.

Verifíquelo en lugar de darlo por supuesto.

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.

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 sustituirá silenciosamente tailscaled por otra versión, y el siguiente reinicio ejecutará código que nunca eligió. La configuración anterior fija v1.102.3, la versión estable de septiembre de 2026. Docker Hub también publica v1.102 para la línea de parches y una etiqueta unstable, que no debe usar en un servidor.

Actualice de forma deliberada.

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. La diferencia entre reiniciar, iniciar y volver a crear merece una lectura antes de depurar un cambio de versión que no se aplicó.

Redes en el espacio de usuario y su coste

TS_USERSPACE está habilitado de forma predeterminada. El contenedor ejecuta entonces una pila TCP/IP en el espacio de usuario y nunca accede a /dev/net/tun. Esto permite que funcione en hosts que no entregan el dispositivo TUN al 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 capacidades.

El tráfico saliente es donde aparece el coste. En el modo de espacio de usuario, 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 esta debe respetarlo. Todo lo que ignore las variables de entorno del proxy no podrá acceder a la tailnet.

La pila tiene algunos límites importantes. Sólo transporta TCP y UDP, por lo que no pasan otros protocolos IP, como SCTP. ICMP se limita a ping, que el daemon reconstruye, y esto añade un poco de 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. Un nodo en el espacio de usuario tampoco puede usar un exit node ni una ruta de subred anunciada por otro nodo, aunque sí puede anunciarlas 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 faltar, y en ese caso el espacio de usuario es la única opción. Proporcione el dispositivo TUN al contenedor si debe actuar como un router de subred que anuncie un rango privado o como un exit node para los demás dispositivos, porque esos son los roles más afectados en el espacio de usuario.

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 a mi contenedor de 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 nada hacia ella y la única forma de acceder es mediante el puerto publicado del host. 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 lo que un sidecar funciona sin dispositivo ni capacidades adicionales. Necesita /dev/net/tun, TS_USERSPACE=false y net_admin cuando el contenedor deba abrir conexiones salientes a la tailnet de forma transparente, o actuar como enrutador de subredes o 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 cualquier pila de larga duración. El etiquetado desactiva la caducidad de la clave del nodo, por lo que el contenedor no desaparece de la tailnet después de 180 días mientras espera a que alguien inicie sesión en él. 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 se mantiene 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. Configure TS_STATE_DIR=/var/lib/tailscale, que no tiene un valor predeterminado fuera de Kubernetes, y monte esa ruta en un bind mount o un volumen con nombre. Configurar uno sin el otro parece correcto hasta el primer docker compose down. Confirme que tailscaled.state exista en el directorio montado mientras la pila esté detenida.