Cómo evitar que Docker omita UFW y bloquear puertos
Docker usa reglas iptables que saltan UFW: aunque deniegues el puerto 8080, sigue accesible desde Internet. Entiende el bypass y aplica dos correcciones.
Por qué Docker omite UFW
Docker omite UFW porque los puertos publicados de los contenedores nunca pasan por las reglas del firewall que administra UFW. Cuando ejecuta docker run -p 8080:80, Docker escribe una regla DNAT (traducción de direcciones de red de destino) en su propia cadena DOCKER, dentro de la tabla nat del kernel, a la que salta la cadena PREROUTING. Esa regla cambia el destino de cada paquete a la dirección privada del contenedor antes de que el kernel decida adónde debe dirigirlo. A continuación, el paquete modificado se reenvía al contenedor mediante la cadena FORWARD, que controla Docker. Las reglas de UFW están en la cadena INPUT, y el paquete nunca entra en ella. Por eso ufw status muestra una política predeterminada de denegación, sudo ufw deny 8080 indica que la operación se realizó correctamente y el puerto 8080 sigue respondiendo a todo Internet.
Esto no es un error de Docker, y UFW no está dañado. Ambas herramientas programan el mismo firewall del kernel. Las reglas de Docker actúan antes en la ruta del paquete, por lo que UFW nunca llega a intervenir. Esta guía demuestra el bypass, explica el mecanismo y presenta las dos soluciones que funcionan: publicar los puertos en 127.0.0.1 y filtrar en la cadena DOCKER-USER. Si UFW aún es nuevo para usted, configúrelo primero con la guía básica del firewall UFW, porque un firewall con denegación predeterminada sigue siendo la base adecuada para todo lo demás en el servidor.
Verifique el bypass en su propio servidor
Empiece con un VPS donde UFW esté activo y tenga una política predeterminada de denegación para el tráfico entrante. Ejecute un contenedor web con un puerto publicado:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose muestra Default: deny (incoming), allow (outgoing) y ninguna regla para el puerto 8080. Según el informe del firewall, el puerto está cerrado. Ahora pruebe desde otra máquina, no desde el propio servidor:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKEl contenedor responde. Añada una regla explícita de denegación y vuelva a probar:
sudo ufw deny 8080/tcpEl puerto sigue respondiendo porque la regla de denegación está en una cadena que el paquete nunca recorre. UFW no falló. Nunca llegó a consultarse. Por eso el problema también pasa tan desapercibido: no se muestra ningún error, el despliegue funciona y la salida del estado del firewall parece exactamente la de un servidor protegido y restringido.
El mecanismo: PREROUTING se ejecuta antes que INPUT
El kernel procesa los paquetes entrantes en un orden fijo, y todo el problema se debe a ese orden.
PREROUTINGse ejecuta primero. Las reglas alcanzadas desde aquí pueden reescribir el destino del paquete, y la regla de Docker para un puerto publicado, en la cadenaDOCKERa la que saltaPREROUTING, hace exactamente eso.- A continuación se toma la decisión de enrutamiento. Un paquete dirigido al propio host pasa a la cadena
INPUT. Un paquete dirigido a otra máquina pasa a la cadenaFORWARD. - Las reglas de UFW están en
INPUT. Las reglas de Docker están enFORWARD.
Consulte la regla de Docker para el contenedor que acaba de iniciar:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80La línea DNAT explica todo. El destino de cualquier paquete que llegue al puerto 8080 se reescribe como 172.17.0.2:80, la dirección del contenedor en la red bridge privada de Docker. Después de la reescritura, el paquete ya no está dirigido al host, por lo que la decisión de enrutamiento lo envía por la ruta FORWARD, donde Docker ya ha añadido reglas que aceptan tráfico hacia sus propias redes. La regla deny 8080/tcp espera en INPUT un paquete que nunca llega.
En Ubuntu 24.04, el comando iptables actúa como una interfaz sobre nftables, pero el orden de las cadenas y el resultado son idénticos. UFW y Docker escriben en la misma canalización de paquetes del kernel, y el punto de entrada de Docker es anterior. Esto no es específico de UFW: firewalld en un VPS con Rocky o AlmaLinux filtra en el mismo punto de esa canalización y la misma regla DNAT lo evita, por lo que las correcciones siguientes también se aplican allí.
La solución habitual: publicar puertos en 127.0.0.1
La mayoría de los contenedores nunca tuvo que ser pública. Una base de datos, un servidor de aplicaciones detrás de un reverse proxy, un panel de administración o un endpoint de métricas no deberían responder directamente a Internet. Publíquelos en la dirección de loopback:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineO en un archivo de Compose:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Esto funciona porque la regla DNAT de Docker ahora sólo coincide con los paquetes dirigidos a 127.0.0.1. Además, en Docker Engine 28.0.0 y posteriores, Docker añade una regla en la tabla raw, en la cadena PREROUTING, que descarta cualquier paquete dirigido a ese puerto de loopback si llega por una interfaz distinta de loopback. En esas versiones, el puerto es accesible desde el host y desde ningún otro lugar. En versiones anteriores a 28.0.0, Docker documenta que los hosts del mismo segmento L2, por ejemplo, los equipos conectados al mismo switch de red, todavía pueden acceder a los puertos publicados en localhost. Compruebe docker version y actualice antes de confiar en este comportamiento. Verifique la asociación:
sudo ss -tlnp | grep 8080La salida debe mostrar 127.0.0.1:8080, no 0.0.0.0:8080 ni [::]:8080. Después, confirme desde otro equipo que curl http://your-vps-ip:8080/ no obtiene respuesta. Un paquete enviado a su dirección pública ya no es reescrito por Docker. Por tanto, llega a INPUT, donde la política predeterminada de denegación de UFW y la regla deny 8080/tcp anterior lo descartan silenciosamente en lugar de rechazarlo. Por eso curl queda bloqueado y termina con un tiempo de espera de conexión, no con "connection refused".
Para los servicios que deban estar expuestos a Internet, ejecute un único reverse proxy que controle los puertos 80 y 443 y enrute las peticiones según el nombre de host. No publique ningún otro puerto. Este es el patrón que implementa la guía de reverse proxy con Traefik, y es la forma en que una aplicación autogestionada como Nextcloud en un VPS permanece inaccesible salvo a través de su proxy. La guía básica de Docker Compose explica cómo se declaran las entradas ports: y el resto del flujo de trabajo de Compose.
Con todos los contenedores internos en loopback, UFW vuelve a cumplir su función normal: proteger los puertos que sirve el propio host. Cree aquí ese conjunto de reglas y ejecute después los comandos en orden:
Filtrado real: la cadena DOCKER-USER
A veces un puerto de contenedor debe seguir publicado en la red, pero con acceso restringido. Por ejemplo, el puerto de una réplica de base de datos puede estar disponible sólo para una dirección de oficina. Para eso, Docker proporciona la cadena DOCKER-USER. Todos los paquetes dirigidos a cualquier contenedor pasan por DOCKER-USER antes de llegar a las reglas de aceptación propias de Docker. La cadena existe para sus reglas y Docker no las elimina ni modifica, incluso después de reiniciar el daemon.
Esta cadena pertenece al backend de firewall iptables predeterminado de Docker. Docker Engine 29 añadió un backend nftables experimental que debe activarse con la opción del daemon firewall-backend. En ese backend no existe la cadena DOCKER-USER: el filtrado personalizado se configura en una tabla nftables independiente, con una cadena base en el hook forward. Si no ha cambiado esa opción, utiliza iptables y todo lo que sigue es aplicable. La diferencia entre ambas herramientas se explica en la guía sobre iptables y nftables.
Hay una particularidad importante antes de ejecutar el comando: cuando un paquete llega a DOCKER-USER, la reescritura DNAT ya se ha realizado. El puerto de destino del paquete es el puerto del contenedor (80 en nuestro ejemplo), no el puerto publicado (8080). Por tanto, una regla que coincida con --dport 8080 no coincide con ningún paquete. El método fiable consiste en buscar el puerto al que se conectó originalmente el cliente. El seguimiento de conexiones del kernel lo conserva:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPLa regla se interpreta así: para los paquetes que entraron por eth0 y pertenecen a una conexión cuyo puerto de destino original era 8080, descartar todo lo que no proceda de 10.0.0.10. La coincidencia --ctdir ORIGINAL limita la regla a la dirección cliente-contenedor, por lo que los paquetes de respuesta no se interceptan por error. Sustituya eth0 por su interfaz pública; ip route | grep default indica su nombre. Pruébelo igual que antes: curl desde la dirección permitida funciona, mientras que desde cualquier otro lugar la conexión agota el tiempo de espera. Esta espera es la señal de que una regla DROP está funcionando, y no de que no haya ningún servicio detrás del puerto. La diferencia entre una conexión rechazada y otra cuyo tiempo de espera se agota es la forma más rápida de distinguir un puerto filtrado de un servicio que simplemente no está escuchando.
Las reglas añadidas con el comando iptables desaparecen al reiniciar. Como UFW ya administra este firewall, el lugar adecuado para conservarlas es /etc/ufw/after.rules. Añada un bloque al final del archivo:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITDespués, ejecute sudo ufw reload. UFW vuelve a aplicar ese archivo en cada recarga y en cada arranque. Así, el filtrado de los contenedores queda en el mismo lugar que el resto del firewall y se conserva tanto tras reiniciar como después de actualizar Docker.
Por qué no debe desactivar la integración de Docker con iptables
Las respuestas antiguas a este problema sugieren establecer { "iptables": false } en /etc/docker/daemon.json. No lo haga. Las reglas de firewall de Docker hacen mucho más que publicar puertos. La regla de masquerade permite que los contenedores accedan a Internet mediante la dirección del host. Por tanto, con la integración desactivada, los contenedores no pueden acceder a repositorios de paquetes ni llamar a ninguna API externa (interfaz de programación de aplicaciones). También desaparecen las reglas de DNAT y de aislamiento. La documentación de Docker advierte que, sin reglas de reemplazo, los puertos de los contenedores quedan accesibles para los hosts de la red local. Para corregir ese acceso no deseado tendría que romper la red de los contenedores, y debería escribir y mantener manualmente todas esas reglas. La propia documentación de Docker considera que esta configuración no es adecuada para la mayoría de los usuarios y describe la creación posterior de las reglas como una tarea extremadamente compleja. La cadena DOCKER-USER existe precisamente para que nadie necesite activar esta opción.
El lado IPv6 del mismo problema
Primero compruebe cómo aparece el puerto publicado en IPv6:
sudo ss -tlnp | grep 8080Desde Docker Engine 27, Docker gestiona ip6tables de forma predeterminada. En una red Docker con IPv6 habilitado, un puerto publicado recibe el mismo tratamiento DNAT en las tablas IPv6. Por tanto, el mismo bypass también existe allí y se aplica la misma corrección: la cadena DOCKER-USER también existe en ip6tables, por lo que debe replicar la regla con sudo ip6tables -I DOCKER-USER ... y probar desde fuera con curl contra la dirección IPv6 pública del servidor, por ejemplo curl -6 http://[2001:db8:2a::1]:8080/. Esto se aplica al backend iptables predeterminado de Docker. En el backend nftables experimental no existe ninguna cadena DOCKER-USER en ninguna de las dos pilas, y el filtrado IPv6 se configura en su propia tabla nftables ip6.
En una red sin IPv6, los clientes IPv6 son gestionados por docker-proxy, un proceso normal en el espacio de usuario que escucha en [::]:8080 y reenvía el tráfico al contenedor mediante IPv4. El tráfico dirigido a un proceso del host sí pasa por INPUT, por lo que UFW puede filtrar esa ruta, pero sólo cuando UFW gestiona IPv6. El modo de comprobarlo y las otras formas en que puede abrirse una brecha IPv6 en un VPS se explican en la guía de UFW e IPv6.
Publicar en loopback evita todo el problema: -p 127.0.0.1:8080:80 sólo se enlaza a loopback IPv4, por lo que no existe ningún listener IPv6. Además, en Docker Engine 28.0.0 y posteriores no hay nada accesible desde fuera en ninguna de las dos pilas.
El patrón que funciona
- Publique todos los puertos internos en
127.0.0.1para que nunca queden expuestos desde el principio, con Docker Engine 28.0.0 o una versión posterior (las versiones anteriores permitían que los hosts del mismo segmento L2 accedieran a ellos). - Asigne la parte pública a un único reverse proxy que controle los puertos 80 y 443.
- Mantenga UFW con la política predeterminada de denegación para el host y permita SSH y los puertos del proxy.
- Filtre los puertos de contenedores que sean realmente públicos en
DOCKER-USER, con el backend predeterminado de iptables de Docker, según el puerto de destino original, y persista la configuración en/etc/ufw/after.rules. - Mantenga activada la integración de iptables de Docker.
Una vez configurado, este patrón elimina las sorpresas: ufw status describe el host y DOCKER-USER describe los contenedores. Nada se publica por accidente y el siguiente docker run -p que ejecute expone exactamente lo que pretendía.
FAQ
¿Por qué puedo acceder a mi contenedor Docker cuando UFW bloquea el puerto?
Porque Docker publica el puerto con una regla DNAT en su cadena DOCKER de la tabla nat, a la que se llega desde PREROUTING. Esta regla cambia el destino del paquete a la dirección del contenedor antes de que se aplique cualquier filtrado. Después, el paquete sigue la ruta FORWARD, mientras que las reglas de UFW están en INPUT, una cadena por la que el paquete nunca pasa. El firewall nunca se consulta, por lo que sus reglas de denegación no afectan a los puertos publicados de los contenedores.
¿Cómo hago que UFW bloquee los puertos publicados por Docker?
UFW no puede hacerlo directamente porque sus reglas están en la cadena incorrecta. Puede dejar de exponer el puerto publicándolo como 127.0.0.1:8080:80, de modo que sólo el host pueda acceder a él en Docker Engine 28.0.0 y posteriores. También puede filtrar en la cadena DOCKER-USER mediante una regla de iptables que coincida con el puerto de destino original a través de conntrack. Guarde esa regla en /etc/ufw/after.rules para que sobreviva a los reinicios y ufw reload.
¿Debo establecer "iptables": false en daemon.json de Docker?
No. Esa configuración elimina la mayoría de las reglas de firewall y NAT de Docker, lo que provoca problemas mucho más amplios que el bypass. Los contenedores pierden el acceso saliente a Internet porque desaparece la regla de masquerade. Además, Docker advierte que, sin reglas alternativas, los puertos de los contenedores quedan accesibles para los hosts de la red local. Use la publicación en loopback y la cadena DOCKER-USER. Estas opciones corrigen la exposición sin romper la conectividad de los contenedores.
¿Docker evita UFW también en IPv6?
En Docker Engine 27 y posteriores, la gestión de ip6tables está habilitada de forma predeterminada. Por tanto, un puerto publicado en una red Docker habilitada para IPv6 se reescribe sin pasar por UFW, exactamente igual que en IPv4. En el backend de iptables predeterminado de Docker, también necesita la misma regla DOCKER-USER reflejada con ip6tables. En el backend experimental de nftables de Engine 29 no existe la cadena DOCKER-USER, por lo que ese filtrado debe configurarse en su propia tabla de nftables. En las redes sin IPv6, el proceso docker-proxy escucha en [::] y ese tráfico sí pasa por INPUT, donde UFW puede filtrarlo si gestiona IPv6. Publicar en 127.0.0.1 evita ambos casos porque no hay ningún proceso escuchando en IPv6.