Activaste UFW. Denegaste todo el tráfico entrante. Te sentiste responsable. Entonces ejecutaste docker compose up
y—sorpresa—tu contenedor es accesible desde Internet de todas formas. No “quizá”. No “solo desde la LAN”.
Accesible. Desde. Todas partes.
Esta es una de esas realidades del networking en Linux que se repiten porque las opciones por defecto están optimizadas para “funciona”
más que para “es seguro”. La buena noticia: puedes asegurarlo en Ubuntu 24.04 sin romper Docker Compose,
sin reemplazar UFW y sin convertir tu host en un experimento de firewall casero.
El modelo mental: por qué UFW y Docker chocan
UFW es una interfaz. Escribe reglas en el firewall del kernel (en Ubuntu 24.04 eso suele ser nftables debajo,
pero muchas herramientas aún hablan la “semántica de iptables”). Docker también es una interfaz. Escribe reglas de firewall para que
la red de contenedores “simplemente funcione”: NAT para salidas, publicación de puertos para entradas e
aislamiento entre bridges.
El conflicto ocurre porque Docker inserta reglas en lugares que UFW no controla, y en prioridades que vencen tu
postura de alto nivel de “denegar entrantes”. Cuando publicas un puerto (-p 8080:80 o en Compose ports:),
Docker programa reglas DNAT y de filtrado para que los paquetes sean reenviados al contenedor. Esos paquetes pueden nunca llegar a la
regla que creías que los bloquearía. UFW dice “denegar”; Docker dice “prometí que ese puerto funcionaría”; Docker gana.
La conclusión práctica: no “arreglas” esto añadiendo más sentencias UFW de permitir y denegar. Lo arreglas
controlando la ruta específica que Docker usa para el tráfico reenviado. Eso significa: entender la ruta de FORWARD,
entender las cadenas de Docker (especialmente DOCKER-USER) y decidir qué debe ser accesible desde dónde.
Una verdad seca: el firewall no es “entrante vs saliente”. Es dirección del tráfico más decisiones de enrutamiento. Los contenedores
no son procesos locales; están detrás de un router virtual. Así que tu política de “entrantes” no siempre se aplica
al tráfico que se reenvía hacia ellos.
Una cita para mantenerte honesto, desde el mundo de la fiabilidad: “La esperanza no es una estrategia.” — Gene Kranz.
Broma #1: La red de Docker es como una tarjeta de acceso de hotel: conveniente hasta que descubres que también abre la puerta de “solo personal”.
Hechos y contexto histórico para usar en discusiones
- UFW se remonta a la era de iptables y aún piensa en esos términos, incluso cuando nftables es el backend.
- Docker históricamente dependió de iptables para implementar NAT y puertos publicados; esa suposición de diseño persiste entre distribuciones.
- Netfilter de Linux tiene múltiples hooks (PREROUTING, INPUT, FORWARD, OUTPUT, POSTROUTING). “Denegar entrantes” de UFW suele apuntar a INPUT, no a FORWARD.
- Docker publica puertos con DNAT; los paquetes dirigidos a la IP del host pueden reescribirse a la IP del contenedor antes de que la cadena INPUT de UFW sea relevante.
- La cadena DOCKER-USER existe específicamente para que los operadores puedan insertar reglas de filtrado que se apliquen antes de las reglas de aceptación propias de Docker.
- Las políticas por defecto de UFW cambiaron con los años, pero el patrón clásico sigue: denegar por defecto en INPUT, permitir established/related, gestionar permisos específicos.
- Ubuntu se movió hacia nftables como backend preferido; los comandos “iptables” pueden ser envoltorios (iptables-nft) que generan reglas nft.
- El “iptables=false” de Docker no es gratis; desactivar la gestión de reglas por parte de Docker rompe comportamientos de red comunes a menos que los reemplaces tú mismo.
- El tráfico entre contenedores en el mismo bridge no es “salida”; es L2/L3 local dentro del host y puede eludir la intención ingenua del firewall a menos que lo filtres.
Guía rápida de diagnóstico
Quieres la ruta más rápida desde “el puerto está expuesto” hasta “sé exactamente qué cadena lo permitió”. Aquí está el orden que
encuentra el cuello de botella rápidamente.
1) Confirma qué está realmente expuesto (no confíes en los archivos Compose)
- Comprueba los puertos publicados desde la vista de Docker (
docker ps). - Comprueba los sockets en escucha en el host (
ss -ltnp). - Haz pruebas desde un sistema remoto o un segundo namespace de red si tienes uno.
2) Identifica la ruta del paquete
- Si es un puerto publicado, probablemente golpea DNAT en nat PREROUTING y luego filter FORWARD.
- Si es networking de host (
network_mode: host), pasa por filter INPUT como cualquier demonio.
3) Inspecciona la regla que gana
- Comprueba
DOCKER-USERprimero (tu punto de control). - Luego revisa las cadenas propias de Docker (
DOCKER,DOCKER-FORWARD). - Después verifica la política de reenvío de UFW y si UFW siquiera ve el paquete.
4) Arregla con el cambio más pequeño que haga verdadera la postura de seguridad
- Si solo necesitas “alcanzable desde LAN”, filtra por subred de origen en
DOCKER-USER. - Si solo necesitas “alcanzable desde un contenedor reverse proxy”, deja de publicar el puerto de la app y usa redes internas.
- Si necesitas “alcanzable solo en localhost”, enlaza los puertos publicados a 127.0.0.1.
Estado objetivo: qué significa realmente “asegurado”
“Contenedores asegurados” es vago. En la práctica, elige uno de estos estados objetivos sensatos e implémentalo explícitamente:
-
Por defecto: nada está publicado. Los contenedores se comunican por redes privadas de Compose. Solo un reverse proxy
(o un servicio gateway único) publica 80/443. -
Publicación selectiva. Un puñado de puertos está publicado, pero solo a redes origen específicas
(VPN corporativa, rangos de oficina) y nunca a Internet entero a menos que el servicio deba ser público. -
Patrón localhost-only para desarrollo en hosts de producción (sí, a veces hace falta): publica en 127.0.0.1 y
requiere túnel SSH, VPN o acceso en el propio host. - Aislamiento estricto entre bridges de Docker. El tráfico entre contenedores se permite solo donde tú lo declares.
Lo que debes evitar: “denegar entrantes por defecto” en UFW y luego dejar que Docker publique puertos libremente. Esa es una contradicción
de política con un resultado predecible.
Tareas prácticas (comandos, salidas, decisiones)
Estas son las tareas que realmente ejecuto en Ubuntu 24.04 cuando diagnostico o endurezo UFW + Docker. Cada una incluye
el comando, lo que suele significar la salida y la decisión que tomas.
Task 1: Verificar estado de UFW y políticas por defecto
cr0x@server:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
Significado: “routed” controla el reenvío (FORWARD). Si está deshabilitado, UFW puede no estar vigilando el
tráfico reenviado como supones.
Decisión: Si los contenedores están expuestos, debes abordar el comportamiento de reenvío (típicamente vía DOCKER-USER),
no solo reglas INPUT.
Task 2: Confirmar que Docker gestiona iptables/nft
cr0x@server:~$ sudo docker info --format '{{json .SecurityOptions}}'
["name=apparmor","name=seccomp,profile=builtin","name=cgroupns"]
Significado: Esto no muestra iptables directamente, pero confirma un entorno Docker normal. Lo siguiente es revisar
la configuración del daemon.
Decisión: Inspecciona /etc/docker/daemon.json antes de asumir algo sobre el comportamiento del firewall de Docker.
Task 3: Comprobar la opción iptables del daemon de Docker
cr0x@server:~$ sudo cat /etc/docker/daemon.json
{
"iptables": true,
"ip-forward": true
}
Significado: Docker programará reglas de firewall. Esa es la situación común/por defecto.
Decisión: Mantenerlo. Desactivarlo es como reinventar NAT a las 2 a.m.
Task 4: Listar puertos publicados según Docker
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES PORTS
web 0.0.0.0:8080->80/tcp
db 5432/tcp
prometheus 127.0.0.1:9090->9090/tcp
Significado: 0.0.0.0:8080 es alcanzable desde todo el mundo (sujeto al firewall). 127.0.0.1:9090
es solo local.
Decisión: Si un servicio no necesita ingreso público, elimina el puerto publicado o enlázalo a localhost.
Task 5: Verificar sockets en escucha en el host (chequeo de realidad)
cr0x@server:~$ sudo ss -ltnp | head -n 12
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=2214,fd=4))
LISTEN 0 4096 127.0.0.1:9090 0.0.0.0:* users:(("docker-proxy",pid=2311,fd=4))
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1042,fd=3))
Significado: Docker-proxy (o NAT del kernel) acepta conexiones en el host. Si está ligado a 0.0.0.0,
tu firewall debe ser correcto o estás expuesto.
Decisión: Trata las ligaduras a 0.0.0.0 como “públicas hasta que se demuestre lo contrario”.
Task 6: Identificar qué backend de firewall estás usando
cr0x@server:~$ sudo update-alternatives --display iptables | sed -n '1,12p'
iptables - auto mode
link best version is /usr/sbin/iptables-nft
link currently points to /usr/sbin/iptables-nft
link iptables is /usr/sbin/iptables
slave iptables-restore is /usr/sbin/iptables-restore
slave iptables-save is /usr/sbin/iptables-save
Significado: Estás usando iptables-nft en modo compatibilidad, lo cual está bien. Lo importante es la consistencia.
Decisión: Evita mezclar reglas nft crudas que compitan con iptables-nft a menos que controles completamente el conjunto de reglas.
Task 7: Inspeccionar la cadena DOCKER-USER (tu gancho de política)
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j RETURN
Significado: No se aplican restricciones antes de las reglas de aceptación de Docker.
Decisión: Aquí es donde añades “denegar por defecto, permitir lo necesario” para puertos publicados.
Task 8: Inspeccionar la política FORWARD y cadenas de reenvío de Docker
cr0x@server:~$ sudo iptables -S FORWARD
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
Significado: DROP por defecto es bueno, pero el salto a DOCKER-FORWARD significa que Docker aún puede permitir flujos específicos.
Decisión: Haz cumplir tus restricciones en DOCKER-USER, antes de que DOCKER-FORWARD acepte tráfico.
Task 9: Listar reglas NAT creadas por Docker para puertos publicados
cr0x@server:~$ sudo iptables -t nat -S DOCKER | head -n 20
-N DOCKER
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.18.0.3:80
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 9090 -j DNAT --to-destination 172.18.0.5:9090
Significado: DNAT reescribe el tráfico dirigido al puerto 8080 del host hacia el contenedor. Por eso las reglas INPUT
no lo son todo.
Decisión: Si quieres bloquear acceso público, bloquéalo en filter FORWARD/DOCKER-USER según IP de origen.
Task 10: Añadir una línea base “denegar por defecto para puertos publicados” en DOCKER-USER
cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
cr0x@server:~$ sudo iptables -I DOCKER-USER 2 -i lo -j ACCEPT
cr0x@server:~$ sudo iptables -I DOCKER-USER 3 -s 10.0.0.0/8 -j ACCEPT
cr0x@server:~$ sudo iptables -I DOCKER-USER 4 -s 192.168.0.0/16 -j ACCEPT
cr0x@server:~$ sudo iptables -A DOCKER-USER -j DROP
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-USER -i lo -j ACCEPT
-A DOCKER-USER -s 10.0.0.0/8 -j ACCEPT
-A DOCKER-USER -s 192.168.0.0/16 -j ACCEPT
-A DOCKER-USER -j RETURN
-A DOCKER-USER -j DROP
Significado: La salida de ejemplo muestra una trampa común: Docker (o reglas previas) puede ya incluir RETURN.
Si RETURN aparece antes de DROP, tu drop no se ejecutará.
Decisión: Asegura que DROP esté antes de cualquier RETURN incondicional, o reemplaza el contenido de la cadena limpiamente.
En la práctica, quieres: permitir established, permitir fuentes confiables, luego dropear, luego retornar (o simplemente drop).
Task 11: Reescribir DOCKER-USER limpiamente para evitar sorpresas de orden de reglas
cr0x@server:~$ sudo iptables -F DOCKER-USER
cr0x@server:~$ sudo iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
cr0x@server:~$ sudo iptables -A DOCKER-USER -s 10.10.0.0/16 -j ACCEPT
cr0x@server:~$ sudo iptables -A DOCKER-USER -s 192.168.50.0/24 -j ACCEPT
cr0x@server:~$ sudo iptables -A DOCKER-USER -j DROP
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-USER -s 10.10.0.0/16 -j ACCEPT
-A DOCKER-USER -s 192.168.50.0/24 -j ACCEPT
-A DOCKER-USER -j DROP
Significado: Determinista: si no provienes de las subredes permitidas y no estás establecido, eres dropeado antes de que Docker acepte nada.
Decisión: Usa esto como base en hosts que no deben publicar servicios arbitrarios a Internet público.
Task 12: Verificar que UFW no esté permitiendo tráfico enrutado en silencio
cr0x@server:~$ sudo grep -nE 'DEFAULT_FORWARD_POLICY|IPV6' /etc/default/ufw
7:DEFAULT_FORWARD_POLICY="DROP"
18:IPV6=yes
Significado: Forward por defecto es DROP. Bien. IPv6 está habilitado; si lo ignoras, puedes “asegurar” IPv4 y aún filtrar por IPv6.
Decisión: Si usas IPv6 (probablemente sí, aunque finjas que no), duplica la política para ip6tables/nft.
Task 13: Revisar las reglas “before” de UFW para interacción con Docker
cr0x@server:~$ sudo sed -n '1,140p' /etc/ufw/before.rules | sed -n '1,40p'
#
# rules.before
#
# Rules that should be run before the ufw command line added rules. Custom
# rules should be added to one of these chains:
# ufw-before-input
# ufw-before-output
# ufw-before-forward
#
Significado: UFW espera que añadas políticas de la ruta de reenvío en ufw-before-forward si hace falta.
Decisión: Prefiere DOCKER-USER para restricciones específicas de Docker. Usa before-forward de UFW para política de enrutamiento más amplia.
Task 14: Confirmar redes de Docker e interfaces bridge
cr0x@server:~$ docker network ls
NETWORK ID NAME DRIVER SCOPE
a1b2c3d4e5f6 bridge bridge local
b2c3d4e5f6a1 myapp_default bridge local
c3d4e5f6a1b2 host host local
d4e5f6a1b2c3 none null local
Significado: Cada bridge definido por el usuario (como myapp_default) puede tener su propia interfaz y comportamiento de aislamiento.
Decisión: Si intentas restringir tráfico este-oeste, debes pensar por bridge, no solo en docker0.
Task 15: Mapear un puerto publicado a una IP de contenedor específica (para reglas precisas)
cr0x@server:~$ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web
172.18.0.3
Significado: Puedes escribir una regla en DOCKER-USER que permita solo tráfico a este contenedor/puerto (útil para excepciones).
Decisión: Prefiere políticas basadas en subred; las IP por contenedor cambian. Usa IPs estáticas solo cuando realmente las necesites.
Task 16: Probar desde una fuente no confiable y observar contadores
cr0x@server:~$ sudo iptables -L DOCKER-USER -v -n
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
40 2400 ACCEPT all -- * * 10.10.0.0/16 0.0.0.0/0
12 720 DROP all -- * * 0.0.0.0/0 0.0.0.0/0
Significado: Los contadores se incrementan. Tus reglas realmente ven tráfico. Esa es la mejor sensación que puedes tener con firewalls.
Decisión: Si los contadores no se mueven, estás filtrando la cadena/hook equivocado (común cuando se usa network_mode: host).
Task 17: Persistir reglas entre reinicios (no confíes en la memoria)
cr0x@server:~$ sudo apt-get update
Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease
Reading package lists... Done
cr0x@server:~$ sudo apt-get install -y iptables-persistent
Reading package lists... Done
Building dependency tree... Done
Suggested packages:
firewalld
The following NEW packages will be installed:
iptables-persistent netfilter-persistent
Setting up iptables-persistent ...
Saving current rules to /etc/iptables/rules.v4...
Saving current rules to /etc/iptables/rules.v6...
Significado: Tus reglas actuales v4/v6 se guardan y restauran al arrancar.
Decisión: Si usas reglas DOCKER-USER, persístelas. De lo contrario el próximo reinicio “arregla” tu firewall volviendo a inseguro.
Task 18: Validar exposición IPv6 (la trampa silenciosa)
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Ports}}' | sed -n '1,5p'
NAMES PORTS
web :::8080->80/tcp
prometheus 127.0.0.1:9090->9090/tcp
Significado: :::8080 es IPv6-any. Si aseguras solo IPv4, todavía eres alcanzable por IPv6.
Decisión: Replica la política DOCKER-USER en ip6tables (o asegúrate de que nft cubra ambas familias).
Broma #2: IPv6 es como esa llave de repuesto bajo el felpudo—todos se olvidan de ella hasta que la persona equivocada la encuentra.
Patrones de Compose que no sabotean tu firewall
1) No publiques lo que no necesitas
La regla de firewall más limpia es la que no necesitas porque el puerto no está expuesto en primer lugar. Compose
facilita publicar todo durante el desarrollo y luego olvidarte de ello.
Prefiere expose: (interno) sobre ports: (publicado). Sí, “expose” es mayormente documentación más comportamiento de red
dentro de Docker, pero la idea clave es: los servicios internos deben ser alcanzables solo por contenedores pares.
2) Enlaza puertos del host a localhost para UIs de administración
Si necesitas Grafana/Prometheus/paneles de administración en un servidor, enlaza a 127.0.0.1 y accede mediante túnel SSH o VPN.
Esto evita jugar al gatito con excepciones del firewall.
En Compose:
ports: ["127.0.0.1:9090:9090"].
Tan simple. No es “seguridad por oscuridad”; es negarse a enrutar el tráfico en absoluto.
3) Usa un reverse proxy como único borde publicado
El mejor patrón de producción: publica 80/443 para un contenedor reverse proxy (o demonio host), y mantén todo lo demás
en redes internas de Docker. Entonces tus reglas de firewall pueden ser aburridas.
Añade una red “internal: true” para backends. Eso le dice a Docker que no proporcione conectividad externa a través de esa red.
No resolverá todos los casos, pero te empuja hacia una topología sensata.
4) Evita network_mode: host a menos que lo necesites
El networking de host evita gran parte de la red virtual de Docker y hace que el contenedor se comporte como un proceso del host.
Eso cambia qué hooks de firewall aplican. También hace que las colisiones de puertos sean divertidas.
Úsalo para agentes de monitorización sensibles al rendimiento o herramientas de red especializadas cuando tengas razón. De lo contrario,
es un atajo que se convierte en responsabilidad durante respuesta a incidentes.
5) Declara redes separadas para “borde público” y “backend privado”
Redes separadas te dan separación razonable. Una red conectada al proxy y la app,
otra conectada solo a backends. También puedes restringir el enrutamiento entre redes a nivel de firewall si hace falta.
La cadena DOCKER-USER: tu punto de apalancamiento
La propia documentación de Docker introdujo DOCKER-USER porque los operadores necesitaban un punto de inserción estable que Docker no reescribiera.
La cadena es saltada desde FORWARD temprano (según la versión), y es el lugar correcto para hacer cumplir “qué orígenes pueden
alcanzar los puertos publicados de contenedores”.
Piénsalo como la “capa de política” sobre la “capa de plomería” de Docker. Docker monta la plomería para que los paquetes lleguen a los contenedores.
Tú defines la política para que solo los paquetes que quieras realmente lleguen.
Qué poner en DOCKER-USER
- Permitir established/related primero (el tráfico de retorno debe funcionar).
- Permitir desde subredes de origen confiables (VPN, oficina, rango de bastión).
- Opcionalmente permitir servicios públicos específicos (p. ej., 80/443 solo al proxy).
- Dropear todo lo demás que se reenvíe a las redes de Docker.
Qué no poner en DOCKER-USER
- Reglas por IP de contenedor salvo que hayas fijado IPs y asumido la deuda operativa.
- Reglas que asuman que solo existe docker0 (Compose crea múltiples bridges).
- Reglas que dropeen sin permitir established primero (romperás rutas de retorno salientes).
Patrones de integración de UFW que funcionan en Ubuntu 24.04
Hay dos estrategias generales que no terminan mal:
-
Mantén UFW para servicios del host; aplica el ingreso a contenedores en DOCKER-USER. Esta es mi recomendación por defecto.
UFW sigue siendo la herramienta amigable para el operador para SSH, node exporters, etc. DOCKER-USER se convierte en el “perímetro de contenedores”. -
Empuja más política hacia la cadena de reenvío de UFW (
ufw-before-forwardy similares). Esto puede funcionar, pero ahora debugueas interacciones entre cadenas gestionadas por UFW y por Docker. Es factible; simplemente no es el camino más rápido.
Mi postura productiva y opinada
Mantén denegar entrantes por defecto en UFW. Permite SSH solo desde fuentes confiables (VPN o bastión). Publica solo 80/443 públicamente.
Todo lo demás o:
- no está publicado en absoluto (redes internas de Docker), o
- está publicado a 127.0.0.1, o
- se permite solo desde subredes privadas vía DOCKER-USER.
IPv6: decide y luego aplica
En Ubuntu 24.04, IPv6 no es un caso marginal. Si tu servidor tiene AAAA, es alcanzable. Si Docker publica en ::,
necesitas una estrategia v6. Esa estrategia puede ser “deshabilitar IPv6 en todas partes” (severo, a veces correcto) o “filtrarlo correctamente”
(más común en entornos modernos).
Tres microhistorias corporativas desde el terreno
Incidente causado por una asunción errónea: “denegar entrantes significa denegar entrantes”
Una empresa SaaS mediana movió un puñado de herramientas internas a un clúster de VM nuevas con Ubuntu 24.04. El equipo de plataforma
tenía un estándar: UFW habilitado, denegar entrantes por defecto, permitir SSH desde la VPN. Desplegaron con Docker Compose un
dashboard interno, una base de datos y una pila de métricas. Parecía ordenado.
La asunción errónea fue silenciosa y clásica: asumieron que “denegar entrantes” de UFW bloquearía cualquier cosa alcanzable vía la
IP del host, incluidos puertos de contenedores. Durante un escaneo externo rutinario (ni siquiera un pentest dedicado), alguien vio
que la página de login del dashboard era alcanzable en un puerto alto. No debía ser pública. Tampoco estaba parcheada recientemente.
La primera respuesta fue “pero UFW está activo; no puede estar expuesto”. La segunda fue mirar docker ps y
ver 0.0.0.0:PORT->CONTAINER. La tercera fue incómoda: habían construido una postura de seguridad a partir de la abstracción de UI en vez de la realidad del flujo de paquetes.
Arreglarlo no fue heroico. Dejaron de publicar el puerto del dashboard, lo pusieron detrás del reverse proxy existente,
y aplicaron una allowlist en DOCKER-USER para los pocos servicios admin que realmente necesitaban acceso directo desde la subnet de la VPN.
La lección que quedó: “entrante” y “reenviado” no son lo mismo, y Docker vive en el terreno reenviado.
Optimización que salió mal: deshabilitar la gestión de iptables de Docker
Otra organización tuvo una revisión de seguridad que no gustó de “aplicaciones que modifican reglas de firewall”. Es un instinto razonable.
El equipo decidió poner "iptables": false en la configuración del daemon de Docker y gestionar todo solo vía UFW.
Lo hicieron en staging, vieron contenedores arrancar y lo llamaron victoria.
El primer problema fue sutil: la conectividad saliente de los contenedores se volvió intermitente de formas difíciles de correlacionar.
Algunas imágenes se descargaban lentamente, algunos webhooks fallaban por timeout, y DNS fallaba intermitentemente según el nodo.
No era “caído”; era “raro”. Lo raro cuesta.
El segundo problema fue operacional: cada proyecto Compose ahora requería NAT y reglas de reenvío personalizadas. Los desarrolladores
no sabían qué puertos se enrutan a dónde porque ya no estaba expresado en el Compose. Las reglas del firewall se convirtieron en conocimiento tribal. Los cambios tardaban más y la respuesta a incidentes se enlenteció.
Finalmente revirtieron el cambio. Docker volvió a gestionar iptables/nft. El equipo de seguridad obtuvo lo que realmente quería—control de política—aplicando restricciones en DOCKER-USER y usando una plantilla estándar para subredes permitidas y puertos de borde públicos. “No dejar que Docker toque iptables” sonaba limpio. En la práctica, sustituyó un mecanismo común por uno a medida. Eso no es seguridad; es deuda con insignia.
Práctica aburrida pero correcta que salvó el día: persistencia de reglas y prueba de reinicio
Una empresa regulada ejecutaba ventanas de mantenimiento trimestrales donde los hosts se reiniciaban, se actualizaban kernels y se aplicaban los cambios habituales.
Un equipo tenía la costumbre: después de cualquier cambio de firewall, (1) persistir reglas, (2) reiniciar un nodo canario, y (3) verificar exposición desde fuera de la subnet. Aburrido. Repetitivo. Molestamente correcto.
Durante una ventana, actualizaron Docker y refrescaron políticas UFW. Todo parecía bien—hasta que el canario reinició.
Su comprobación externa mostró un puerto de servicio abierto que debía ser solo VPN. El equipo no entró en pánico; siguió su checklist y notó que la cadena DOCKER-USER estaba presente pero vacía tras el reinicio. Las reglas no se habían persistido.
Como lo detectaron en un canario, el impacto fue pequeño: reinstalaron la herramienta de persistencia, guardaron reglas v4 y v6,
y validaron de nuevo. El resto de la flota siguió con la línea base corregida. Sin incidente. Sin correo a clientes.
Sin reunión de “cómo pasó esto en la revisión”.
La moraleja no es glamurosa: si no pruebas un reinicio, no tienes una configuración. Tienes un humor.
Errores comunes: síntomas → causa raíz → solución
1) “UFW está habilitado pero el puerto del contenedor sigue siendo alcanzable”
Síntoma: Clientes remotos alcanzan host:8080 incluso con denegar entrantes por defecto.
Causa raíz: El tráfico se DNATea y reenvía; la política INPUT de UFW no aplica. Las reglas de Docker lo aceptan.
Solución: Añade allowlist + drop en DOCKER-USER, o deja de publicar el puerto.
2) “Añadí DOCKER-USER DROP pero no cambió nada”
Síntoma: Los contadores no se mueven; el puerto sigue abierto.
Causa raíz: Estás usando network_mode: host, o tu regla DROP está después de un RETURN incondicional, o estás filtrando solo IPv4.
Solución: Revisa iptables -S DOCKER-USER por orden de reglas; verifica network_mode: host; replica reglas en ip6tables.
3) “Tras reinicio, todo vuelve a exponerse”
Síntoma: La política funciona hasta un reinicio.
Causa raíz: Las reglas DOCKER-USER no se persistieron; solo cambió el estado en tiempo de ejecución.
Solución: Usa iptables-persistent o una unidad systemd que restaure reglas antes de que Docker arranque.
4) “Solo algunos usuarios pueden conectar; otros hacen timeout”
Síntoma: Usuarios de VPN funcionan, usuarios de oficina no (o viceversa).
Causa raíz: La allowlist por origen no incluye todas las subredes reales del cliente; el NAT hace que la fuente parezca distinta.
Solución: Valida la IP de origen del cliente en el servidor (tcpdump/conntrack), luego extiende la allowlist deliberadamente.
5) “El tráfico entre contenedores está bloqueado inesperadamente”
Síntoma: La app no alcanza la BD aunque ambas estén en el mismo proyecto Compose.
Causa raíz: DROP demasiado amplio en DOCKER-USER sin excepciones para tráfico interno del bridge.
Solución: Permite established/related, y si dropeas por defecto, añade permisos explícitos para subredes o interfaces internas antes del drop.
6) “IPv4 está asegurado, pero los escáneres aún ven puertos abiertos”
Síntoma: Un escaneo externo muestra puertos abiertos a pesar de reglas IPv4.
Causa raíz: Exposición IPv6 (:::) con política de la familia ip6tables/nft faltante.
Solución: Implementa política equivalente para IPv6, o deshabilita IPv6 intencionalmente (host + Docker) y verifica.
7) “Las actualizaciones de Docker Compose rompen mi firewall”
Síntoma: Tras compose up, las reglas cambian y el acceso varía.
Causa raíz: Tus restricciones están en cadenas gestionadas por Docker en vez de DOCKER-USER, o confías en nombres de interfaz que cambian.
Solución: Mantén la política en DOCKER-USER y usa criterios de coincidencia estables (subredes de origen, puertos de destino, estado conntrack).
Listas de verificación / plan paso a paso
Plan A (recomendado): publicar solo puertos de borde, restringir todo lo demás
-
Inventario de puertos expuestos: ejecuta
docker psyss -ltnp; lista todo lo ligado a 0.0.0.0 o :::. -
Eliminar publicaciones accidentales: elimina
ports:de servicios internos; usa redes internas. -
Enlazar herramientas admin a localhost: usa
127.0.0.1:PORT:PORTen Compose cuando corresponda. - Decidir rangos de origen confiables: subredes VPN, subredes de oficina, IPs de bastión. Escríbelos.
- Hacer cumplir en DOCKER-USER: permitir established/related, permitir rangos confiables, dropear el resto.
- Replicar para IPv6: añade reglas equivalentes en ip6tables o asegura que nft cubra ambas familias.
- Persistir reglas: instala la herramienta de persistencia y valida un reinicio.
- Probar externamente: valida desde una red no confiable y desde una confiable.
- Monitorear contadores: revisa contadores de DOCKER-USER durante pruebas; confirma que tus reglas están realmente en la ruta.
Plan B: política centrada en UFW (solo si disfrutas trazar cadenas)
- Configura la política de reenvío de UFW a DROP (comúnmente ya es así) y asegura el filtrado enrutado según desees.
- Añade permisos explícitos de reenvío para bridges de Docker y servicios publicados en las reglas before-forward de UFW.
- Verifica que Docker no inserte reglas de aceptación que evadan tu intención (probablemente volverás a DOCKER-USER).
Plan C: “Nada se publica nunca” (para plataformas internas)
- Aplica una comprobación en CI que rechace Compose files con
ports:salvo aprobación. - Requiere ingreso mediante una capa reverse proxy estandarizada y descubrimiento de servicios interno.
- Dropear tráfico reenviado a bridges de Docker desde orígenes no confiables universalmente.
Preguntas frecuentes
1) ¿Por qué UFW no bloquea por defecto los puertos publicados por Docker?
Porque el tráfico de contenedores publicado típicamente se reenvía después de NAT, y la postura “denegar entrantes” de UFW gobierna mayormente
INPUT. Docker instala reglas de reenvío/NAT para garantizar que la publicación de puertos funcione.
2) ¿Debo deshabilitar la gestión de iptables de Docker?
No, no como primer movimiento de seguridad. Sustituir un mecanismo estándar y conocido por reglas NAT y de reenvío manuales que ahora posees para siempre no es ideal. Usa DOCKER-USER para imponer política mientras Docker mantiene la plomería funcionando.
3) ¿Cuál es la mejor solución única que no rompe Compose?
Añadir reglas de allowlist + drop en DOCKER-USER, luego eliminar ports: innecesarios de Compose. Esto preserva la
red de Docker mientras previene la “exposición sorpresa a Internet”.
4) ¿Las reglas DOCKER-USER sobreviven reinicios de Docker?
Normalmente sobreviven reinicios del daemon de Docker porque la cadena está pensada para la política del usuario, pero no necesariamente
sobreviven un reinicio del host a menos que las persistas. Persiste las reglas explícitamente.
5) ¿Cómo restrinjo un puerto publicado a solo la LAN?
Permite la(s) subred(es) LAN en DOCKER-USER y dropea todo lo demás. Alternativamente, enlaza el puerto publicado a una IP de interfaz que exista solo en la LAN, pero filtrar por origen suele ser más claro.
6) ¿Qué pasa con contenedores que deben ser públicos (como una app web)?
Publica solo el reverse proxy (80/443) públicamente. Mantén las apps sin publicar en redes internas. Si debes publicar la app directamente, permite solo esos puertos desde 0.0.0.0/0 y mantiene todo lo demás dropeado.
7) ¿Cambia algo para contenedores con network-mode host?
Sí. Los contenedores en host-network se comportan como procesos del host; el tráfico llega a INPUT, no a FORWARD. Para esos, las reglas de UFW son el punto de control correcto, no DOCKER-USER.
8) ¿Cómo sé si IPv6 está exponiendo mis contenedores?
Busca ::: en el listado de puertos de docker ps o en ss -ltnp. Luego prueba desde un host con IPv6 y confirma que tu política en ip6tables/nft coincida con la intención en IPv4.
9) ¿Puedo hacer todo esto puramente en nftables?
Puedes, pero si Docker usa la compatibilidad iptables-nft, querrás evitar gestión de reglas conflictivas. El enfoque pragmático en Ubuntu es: mantener el comportamiento iptables-nft de Docker y aplicar política en DOCKER-USER (y su equivalente v6).
10) ¿Cuál es la forma más limpia de evitar reglas de firewall por contenedor?
No publiques puertos para servicios internos. Usa redes internas de Docker más un proxy de borde. Entonces tu firewall será mayormente
“permitir 80/443, permitir SSH desde VPN, dropear el resto”, con DOCKER-USER asegurando que los contenedores no lo eludan.
Conclusión: próximos pasos que perduran
La forma fiable de asegurar Docker en Ubuntu 24.04 no es luchar contra la red de Docker. Deja que Docker haga la plomería. Tú haces
la política. Pon tu política donde realmente importa: en la ruta de reenvío, antes de las aceptaciones de Docker, vía DOCKER-USER.
Si quieres una hora práctica:
- Ejecuta las tareas de inventario:
docker ps,ss -ltnpy revisa vinculaciones IPv6. - Elimina
ports:accidentales en Compose; reemplázalos con redes internas o binds a localhost. - Implementa una allowlist DOCKER-USER para rangos de origen confiables y luego un drop por defecto.
- Persiste reglas v4 y v6 y verifica con un reinicio en un host canario.
- Escribe tu modelo de exposición deseado (borde público vs solo VPN vs interno) para que el próximo ingeniero no reintroduzca la “exposición sorpresa a Internet”.
Terminarás con algo raro en el mundo de contenedores: una postura de firewall que coincide con lo que crees que desplegaste. Eso no es solo seguridad. Es cordura operativa.