Problema

Al montar varios servicios (gateway LLM, agente de mensajería, servidor multimedia y escritorio remoto) en una única VM de 4 vCPU, es frecuente encontrarse con que los contenedores arrancan, los puertos parecen abiertos y, sin embargo, nada responde. Los logs aparecen vacíos o con mensajes genéricos como “command not found” o “permission denied”. La raíz suele ser una combinación de reglas de firewall invisibles, políticas de reinicio que no reintentan, y permisos de usuario que no coinciden con los archivos creados durante la instalación.

Este patrón se repite en cualquier stack auto‑hosteado que combine Docker, herramientas de red tipo Tailscale y procesos que dependen de usuarios no‑root. El síntoma típico es:

  • Puertos accesibles desde el exterior pero tráfico bloqueado internamente.
  • Contenedores que fallan al iniciar y no se vuelven a lanzar.
  • Servicios que lanzan errores de “npm no está disponible” o “node: command not found” sin referencia a permisos.
  • Transcodificaciones inesperadas en servidores multimedia.

Causa

1. Firewall y cadena FORWARD ignorada por UFW

UFW solo inspecciona la cadena INPUT. Docker crea sus propias reglas en la cadena DOCKER y redirige tráfico a través de FORWARD. Si UFW está activo con política deny en INPUT, el tráfico que pasa por FORWARD sigue fluyendo, creando una falsa sensación de seguridad.

2. Bind a 0.0.0.0 sin restricción de interfaz

Usar -p 8080:8080 expone el puerto en todas las interfaces, incluida la interfaz de Docker (docker0). Cuando la VM tiene una interfaz de túnel (Tailscale) sin dirección asignada aún, el contenedor intenta enlazar esa IP y falla, provocando un exit 128 sin reintentos.

3. Política de reinicio unless-stopped

Docker solo reinicia contenedores que hayan arrancado correctamente. Si el contenedor falla en la fase de creación (por ejemplo, por una IP no disponible), no se cuenta como “started” y la política no lo vuelve a lanzar.

4. Permisos de usuario y rutas de instalación

Instalar npm o node como root genera enlaces simbólicos en /root/.npm con permisos 700. Cuando el contenedor o el servicio corre bajo otro UID, el binario no es visible, generando “command not found”. Lo mismo ocurre con directorios creados por scripts que terminan con mkdir sin los permisos adecuados.

5. Cadenas de iptables de Tailscale

Tailscale inserta la cadena ts-input al principio de INPUT y acepta todo el tráfico del tailnet. Si una regla personalizada se coloca después, nunca se evalúa. Además, la cadena ts-forward puede interferir con la NAT de Docker.

6. Media server y transcodificación inesperada

Los archivos H.264 con audio E‑AC3 son reproducibles en navegadores que no soportan ese codec. El servidor (Plex, Jellyfin) decide transcodificar el audio, consumiendo CPU sin necesidad. La falta de inspección previa del contenedor de medios lleva a sobrecarga inesperada.

7. Trampas de shell comunes

  • rmdir -p que sube niveles y borra directorios críticos.
  • Expansión de palabras que rompe rutas con espacios ($DIR/* sin comillas).
  • pkill -f que coincide con la propia línea de comando SSH y corta la sesión.

Solución

A. Alinear firewall con Docker

  1. Desactivar UFW o configurarlo para que también inspeccione FORWARD:
    ufw default allow forward
    ufw reload
    
  2. Alternativamente, crear reglas explícitas en la cadena DOCKER antes de que Docker las sobrescriba:
    iptables -I DOCKER-USER -i tailscale0 -j ACCEPT
    

B. Enlazar a interfaces específicas

En los docker run o en docker‑compose.yml usar la opción --network host solo cuando sea necesario, o especificar la IP de la interfaz pública:

ports:
  - "192.168.1.10:8080:8080"

Esto evita que Docker intente enlazar una IP que aún no existe (por ejemplo, la de Tailscale).

C. Política de reinicio robusta

Utilizar restart: on-failure o crear una unidad systemd que vigile la disponibilidad de la IP y lance el contenedor cuando sea válida:

[Unit]
Description=LLM gateway con espera de Tailscale IP
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/wait‑tailscale‑ip.sh
ExecStartPost=/usr/bin/docker start llm-gateway
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

wait‑tailscale‑ip.sh:

#!/usr/bin/env bash
while ! ip -4 addr show tailscale0 | grep -q "inet "; do
  sleep 2
done

D. Uniformizar usuarios y rutas

Instalar node y npm mediante nvm o paquetes del sistema que se ubiquen en /usr/local/bin. Evitar ejecutar npm install -g como root. Si se necesita root, crear enlaces en /usr/local/bin con permisos 755.

E. Posicionar reglas de Tailscale

Insertar reglas personalizadas en la posición 1 de INPUT para que se evalúen antes que ts-input:

iptables -I INPUT 1 -p tcp --dport 8080 -j ACCEPT

Comprobar contadores con iptables -L -v -n.

F. Pre‑chequeo de medios

Antes de lanzar el contenedor de Jellyfin, escanear la biblioteca y generar un informe de codecs:

ffprobe -v error -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "$file"

Re‑encode solo los archivos que realmente lo requieran, evitando transcodificaciones en tiempo real.

G. Buenas prácticas de shell

  • Siempre citar variables que contengan rutas: "$DIR/*".
  • Usar rm -r -- "$DIR" en lugar de rmdir -p.
  • Limitar pkill a procesos específicos: pkill -f "^ssh .*@myhost$".

Cuándo aplicar esta solución

  • Síntomas de firewall invisible: puertos accesibles externamente pero servicios no responden, ufw status muestra default deny mientras netstat -tlnp lista los puertos.
  • Contenedores que no reinician: logs con exit code 128 y política unless-stopped.
  • Errores de comando no encontrado después de una instalación como root.
  • Transcodificación constante en servidores de medios sin razón aparente.
  • Fallos intermitentes al usar Tailscale: la IP del túnel aparece después de que el contenedor ya intentó iniciar.

No aplica cuando la arquitectura no usa Docker (por ejemplo, VMs con systemd‑nspawn) o cuando el firewall se gestiona exclusivamente con iptables sin UFW.

Código

# 1. Permitir forward en UFW
ufw default allow forward
ufw reload

# 2. Regla iptables antes de ts-input
iptables -I INPUT 1 -p tcp --dport 8080 -j ACCEPT

# 3. docker‑compose fragmento con bind a IP fija
services:
  llm-gateway:
    image: my/llm-gateway
    ports:
      - "203.0.113.5:8000:8000"
    restart: on-failure

# 4. systemd unit para esperar IP de Tailscale
cat > /etc/systemd/system/llm-gateway-wait.service <<'EOF'
[Unit]
Description=Wait for Tailscale IP and start LLM gateway
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/wait-tailscale-ip.sh
ExecStartPost=/usr/bin/docker start llm-gateway
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF

chmod +x /usr/local/bin/wait-tailscale-ip.sh
systemctl enable llm-gateway-wait
systemctl start llm-gateway-wait

Verificación

  1. Firewall: iptables -L INPUT -v -n debe mostrar contadores incrementándose en la regla de puerto 8080.
  2. Contenedor: docker ps -a debe mostrar el contenedor en estado running y sin RestartCount.
  3. Permisos: which node y node -v deben ejecutarse bajo el UID del contenedor.
  4. Media: reproducir un archivo con audio E‑AC3 y confirmar que el servidor no inicia transcodificación (monitorizar CPU).
  5. Tailscale: tailscale status debe listar la IP asignada y ping <ip> desde otro nodo del tailnet debe responder.

Notas adicionales

  • Cuando se usa docker compose up -d, Docker crea la cadena DOCKER-USER. Colocar reglas allí garantiza que se apliquen antes de la NAT de Docker.
  • En entornos con varios túneles (WireGuard, Tailscale), asignar direcciones estáticas en la configuración del túnel simplifica los binds.
  • Los contenedores que dependen de GPUs pueden fallar al iniciar si la interfaz de red no está disponible; aplicar la misma lógica de espera de IP evita errores de inicialización.
  • Mantener un script de auditoría (audit.sh) que recorra docker inspect, iptables -L y ufw status ayuda a detectar desalineaciones antes de que el stack entre en producción.