Tu firewall te está mintiendo: endurecimiento de redes Docker para sistemas multi-agente
- 1Lock down Docker networks
- 2Run containers as non-root
- 🏆Identity in front of every port
Comienzas con un agente. Luego necesita una base de datos. Después agregas un segundo agente, un puente de mensajería, una pila de observabilidad. Seis meses después, una única instancia de WEC está ejecutando cinco proyectos de compose, veinte y tantos contenedores, y nadie recuerda qué puertos están abiertos al mundo.
Eso no es un hipotético — esa es la caja en la que se escribió este tutorial. Así que en lugar de teorizar, lo investigamos: ¿pueden los contenedores alcanzarse entre sí a través de pilas? ¿Pueden alcanzar las bases de datos? ¿Está el firewall realmente protegiendo algo?
Tres de las respuestas me sorprendieron. Una de ellas fue una base de datos sentada allí sin contraseña. Y el firewall — el firewall estaba mintiendo.
La caja que estamos auditando
Cinco proyectos de compose independientes, desplegados durante meses, cada uno con su propia red (host, none y bridge son los integrados de Docker, más uno sobrante de un servicio que no está en ejecución):
docker network ls
NETWORK ID NAME DRIVER SCOPE
655850b24125 bridge bridge local
05e5d49e7e9d evolution-api_default bridge local
e8bd75ecbb0a host host local
ce697baac173 langfuse_default bridge local
ead3e0822ce6 nlp-evaluation-service_nlp-net bridge local
5d0ab32d14d7 none null local
9a6727150f2b openclaw_default bridge local
ec77b98f457b rag-service_default bridge local
5f88cabf97f0 remark42_default bridge local
Y aquí está lo que se publica al mundo:
docker ps --format '{{.Names}}\t{{.Ports}}' | grep '0.0.0.0'
remark42-remark42-1 0.0.0.0:8082->8080/tcp, [::]:8082->8080/tcp
evolution-api-evolution-api-1 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp
langfuse-langfuse-web-1 0.0.0.0:3001->3000/tcp, [::]:3001->3000/tcp
langfuse-minio-1 0.0.0.0:9090->9000/tcp, [::]:9090->9000/tcp, 127.0.0.1:9091->9001/tcp
Figura 1. El inicio de cualquier auditoría. Nueve redes: tres integradas de Docker (bridge, host, none), cinco proyectos de compose, y uno sobrante. Cuatro servicios publicados en 0.0.0.0 — cada interfaz, incluida la pública.
Cuatro servicios escuchando en cada interfaz. Nota lo que no está en esa lista: los Postgres, ClickHouse y Redis detrás de Langfuse. Esos fueron publicados en 127.0.0.1 en su lugar — alguien tomó una buena decisión allí. Mantén ese pensamiento.
Prueba 1 — ¿Aislan realmente las redes Docker separadas? (sí, y la mayoría de los blogs están equivocados)
La preocupación clásica: OpenClaw es comprometido, y el atacante entra en la base de datos de Langfuse al lado. Vamos a averiguarlo en lugar de adivinar. Obtén las IPs de los contenedores:
for c in langfuse-postgres-1 evolution-api-evolution-postgres-1; do
docker inspect -f '{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $c
done
/langfuse-postgres-1 172.19.0.5
/evolution-api-evolution-postgres-1 172.23.0.4
Ahora intenta alcanzar ambos desde dentro del contenedor de OpenClaw, por IP — sin DNS, sin atajos:
docker exec openclaw-openclaw-gateway-1 node -e '
const net=require("net");
[["langfuse-postgres","172.19.0.5",5432],["evolution-postgres","172.23.0.4",5432]]
.forEach(([n,ip,p])=>{
const s=net.connect({host:ip,port:p,timeout:4000});
s.on("connect",()=>{console.log(`ALCANZADO ${n}`);s.destroy()});
s.on("timeout",()=>{console.log(`bloqueado ${n}`);s.destroy()});
s.on("error",e=>console.log(`error ${n} ${e.code}`));
});'
bloqueado langfuse-postgres
bloqueado evolution-postgres
Figura 2. Conectando por IP cruda desde el contenedor de una pila a la base de datos de otra pila: ambos se agotan. Las reglas de aislamiento de Docker están haciendo su trabajo.
Bloqueado. Las instalaciones modernas de Docker instalan reglas de DOCKER-ISOLATION-STAGE que eliminan el tráfico entre redes de puente definidas por el usuario, y funcionan. Si has leído que "las redes Docker no realmente aíslan", pruébalo en tu propia caja antes de creerlo — en Docker actual, sí lo hacen.
Puedes verificar el aislamiento de red empíricamente en lugar de confiar en el folclore: obtén la IP del objetivo con docker inspect, luego conecta por IP desde dentro de otro contenedor.
Así que el aislamiento entre pilas está bien. Lo que significa que el verdadero riesgo está en otro lugar.
Prueba 2 — Dentro de una red, todo está completamente abierto
Cada proyecto de compose coloca todos sus servicios en una red. Esa es la predeterminada, y significa que el contenedor de la aplicación puede alcanzar cada servicio de respaldo — lo cual es intencionado. La pregunta es cómo se ve realmente ese acceso. Desde el contenedor de puente de Evolution API:
docker exec evolution-api-bridge-1 python3 -c '
import socket
for name, host, port in [("postgres","evolution-postgres",5432),
("redis","evolution-redis",6379),
("api","evolution-api",8080)]:
s=socket.socket(); s.settimeout(4)
try:
s.connect((host,port)); print(f"ALCANZADO {name}:{port}")
except Exception as e:
print(f"bloqueado {name}:{port} -- {type(e).__name__}")
finally: s.close()'
ALCANZADO postgres:5432
ALCANZADO redis:6379
ALCANZADO api:8080
Alcanzable es esperado. No autenticado no lo es. Verifica si esos servicios realmente piden credenciales — primero Redis:
docker exec evolution-api-bridge-1 python3 -c '
import socket
s=socket.socket(); s.settimeout(5); s.connect(("evolution-redis",6379))
s.sendall(b"INFO server\r\n")
print(s.recv(200).decode(errors="replace")[:120])'
$625
# Server
redis_version:7.4.9
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:b61b4eb609520881
redis_
Figura 3. El hallazgo: INFO server respondió sin credenciales (salida truncada a 120 caracteres por la sonda). Cualquier contenedor en esta red puede leer o borrar el almacén de sesiones.
Eso es un comando INFO respondido sin AUTH — el Redis que mantiene el estado de sesión de este despliegue no tiene contraseña. Cualquier cosa que obtenga ejecución de código en cualquier contenedor en esa red puede leerlo, escribir en él, o FLUSHALL en él.
Postgres, en la misma pila, se comporta correctamente:
docker exec evolution-api-bridge-1 python3 -c '
import socket,struct
s=socket.socket(); s.settimeout(5); s.connect(("evolution-postgres",5432))
msg=b"user\x00postgres\x00database\x00postgres\x00\x00"
s.sendall(struct.pack("!ii",len(msg)+8,196608)+msg)
print(s.recv(64))'
b'R\x00\x00\x00\x17\x00\x00\x00\nSCRAM-SHA-256\x00\x00'
Exige SCRAM-SHA-256. La misma caja, la misma red, dos servicios de respaldo — uno pide una contraseña, el otro no. Nadie decidió eso; es simplemente lo que las imágenes predeterminan cuando no estableces una contraseña.
La lección: "red interna" no es un límite de seguridad. Es una conveniencia. Trata cada servicio como si el atacante ya estuviera en esa red, porque si un contenedor cae, ellos están.
Prueba 3 — El firewall que no es
Ahora lo grande. Activa UFW y deniega todo lo entrante excepto SSH:
sudo ufw allow 22/tcp # SIEMPRE primero, o te bloqueas
sudo ufw default deny incoming
sudo ufw --force enable
sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip
To Action From
-- ------ ----
60000:61000/udp ALLOW IN Anywhere
22/tcp ALLOW IN Anywhere
60000:61000/udp (v6) ALLOW IN Anywhere (v6)
22/tcp (v6) ALLOW IN Anywhere (v6)
Denegar entrante — solo SSH y el rango UDP de mosh permitido. Ahora, desde una máquina diferente, abre la interfaz de usuario de Langfuse y la API de Evolution:
http://<instance-ip>:3001 -> La interfaz de usuario de Langfuse se carga normalmente
http://<instance-ip>:8080 -> {"status":200,"message":"Bienvenido a la API de Evolution..."}

Figura 4. El firewall dice que deniega todo lo entrante. La API de Evolution en :8080 responde de todos modos — estado 200, directamente desde un navegador en otra máquina.
Ambos completamente abiertos, a través de un firewall configurado para bloquearlos. Esta es la sorpresa de seguridad más común en Docker auto-alojado, y no es un error.
Por qué: Docker llega al paquete primero
Mira el orden de la cadena FORWARD:
sudo iptables -L FORWARD -n --line-numbers | head -8
Chain FORWARD (policy DROP)
num target prot opt source destination
1 DOCKER-USER all -- 0.0.0.0/0 0.0.0.0/0
2 DOCKER-FORWARD all -- 0.0.0.0/0 0.0.0.0/0
3 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
4 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
5 ufw-before-logging-forward all -- 0.0.0.0/0 0.0.0.0/0
6 ufw-before-forward all -- 0.0.0.0/0 0.0.0.0/0
Figura 5. La explicación completa en una pantalla: el firewall dice denegar entrante, y las cadenas de Docker están en las posiciones 1-2 mientras que las de UFW comienzan en 5.
Las cadenas de Docker están en las posiciones 1 y 2. Las de UFW no aparecen hasta 5 y 6. Y publicar un puerto no es un oyente en el host — es una regla de NAT:
sudo iptables -t nat -L DOCKER -n | grep -E '3001|8080'
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.23.0.3:8080
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:3001 to:172.19.0.2:3000
El paquete llega, el NAT de Docker reescribe el destino al contenedor, y se convierte en tráfico reenviado que la propia cadena de Docker acepta — mucho antes de que se consulte cualquier regla de UFW. Tus reglas de UFW no son ignoradas; simplemente nunca se alcanzan.
ufw deny no protege un puerto Docker publicado. Si has estado confiando en UFW frente a -p 8080:8080, ese puerto ha estado abierto todo el tiempo.
Cuatro soluciones, en orden de preferencia
Solución 1 — No lo publiques en absoluto
La solución más limpia no es una regla de firewall; es no abrir la puerta. Si un servicio solo es consumido por otros contenedores, no necesita ninguna entrada ports: — el tráfico de servicio a servicio funciona a través de la red de compose por nombre.
Si necesitas que sea accesible desde el host (un puerto de depuración, un psql local), enlázalo explícitamente al loopback:
services:
postgres:
# prefijo 127.0.0.1 = solo host, nunca la interfaz pública
ports:
- "127.0.0.1:5433:5432"
Eso es exactamente lo que la pila de Langfuse en esta caja ya hace — su Postgres, ClickHouse y Redis están todos en 127.0.0.1, que es por qué nunca aparecieron en nuestra auditoría de exposición. El predeterminado "5433:5432" significa 0.0.0.0 — cada interfaz, incluida la pública.
Solución 2 — Divide las redes por zona de confianza y marca los backends como internal
Una red por proyecto de compose es la predeterminada, no un diseño. Da a los servicios de respaldo su propia red, y coloca solo la aplicación en ambas:
services:
app: # habla con el mundo Y con la base de datos
image: your-agent
networks: [frontend, backend]
db:
image: redis:7
command: redis-server --requirepass ${REDIS_PASSWORD}
networks: [backend] # solo backend — sin ruta hacia afuera
public: # por ejemplo, un receptor de webhook
image: your-public-thing
networks: [frontend] # no puede ver la base de datos en absoluto
networks:
frontend:
backend:
internal: true # sin gateway: sin entrada, sin salida
Verificado en la misma caja:
# el contenedor solo de frontend intenta alcanzar la base de datos
docker exec netsecdemo-public-1 python3 -c '
import socket
s=socket.socket(); s.settimeout(4)
try: s.connect(("db",6379)); print("ALCANZADO db <- fuga")
except Exception as e: print(f"bloqueado: {type(e).__name__}")'
# la app, que está en ambas redes
docker exec netsecdemo-app-1 python3 -c '
import socket
s=socket.socket(); s.settimeout(4)
try: s.connect(("db",6379)); print("ALCANZADO db <- correcto, lo necesita")
except Exception as e: print(f"bloqueado: {type(e).__name__}")'
# y puede la base de datos alcanzar Internet?
docker exec netsecdemo-db-1 timeout 6 getent hosts pypi.org >/dev/null \
&& echo "resuelto DNS externo" || echo "sin salida a Internet <- interno funcionando"
bloqueado: gaierror
ALCANZADO db <- correcto, lo necesita
sin salida a Internet <- interno funcionando

Figura 6. Tres propiedades probadas en una ejecución — el contenedor de cara pública ni siquiera puede resolver la base de datos, la app puede alcanzarla, y la base de datos no tiene ruta a Internet.
Tres propiedades a la vez: el contenedor de cara pública ni siquiera puede resolver la base de datos, la app puede, y la base de datos no puede comunicarse — lo cual importa el día que una dependencia tuya se vuelva maliciosa.
Solución 3 — Autentica todo, incluidos los servicios "internos"
Nuestra auditoría encontró a Redis respondiendo INFO sin credenciales. Una línea lo soluciona:
db:
image: redis:7
command: redis-server --requirepass ${REDIS_PASSWORD}
-NOAUTH Authentication required.
Esa es la salida real de esta caja, después de aplicar la solución — el hallazgo de la Prueba 2 está cerrado. Si estableces una contraseña en un Redis que algo ya usa, recuerda actualizar la cadena de conexión del cliente también (redis://:<password>@host:6379/0), o cambiarás una base de datos abierta por una rota.
Haz esto incluso para servicios que no están publicados. La defensa en profundidad significa que la segunda capa se mantiene cuando la primera falla.
Solución 4 — Cuando debes publicar, filtra en DOCKER-USER
A veces un puerto realmente tiene que ser de cara pública pero restringido por origen. La cadena a usar es DOCKER-USER — Docker garantiza que se ejecute antes de sus propias reglas de aceptación, y nunca la sobrescribe:
# solo 203.0.113.0/24 puede abrir nuevas conexiones al puerto 3000 del contenedor
sudo iptables -I DOCKER-USER 1 -p tcp --dport 3000 \
-m conntrack --ctstate NEW ! -s 203.0.113.0/24 -j DROP
Probado en vivo en el puerto de Langfuse, desde un navegador en otra máquina:
http://<instance-ip>:3001 -> La interfaz de usuario de Langfuse se carga
ERR_CONNECTION_TIMED_OUT

Figura 7. Misma URL, mismo firewall, una regla en la cadena correcta — y el puerto está finalmente cerrado. Nota la propia sugerencia de Chrome: "Verificando el proxy y el firewall." Esta vez el firewall realmente es la respuesta.
Mientras tanto, la API de Evolution en :8080, que deliberadamente dejamos sola, siguió respondiendo. La regla es quirúrgica, y funciona exactamente donde UFW no pudo.
iptables -I no es persistente. Usa iptables-persistent (netfilter-persistent save), o coloca la regla en una pequeña unidad de systemd que se ejecute después de docker.service. Una regla de firewall que desaparece al reiniciar es peor que ninguna, porque pensarás que estás protegido.
Puedes auditar y asegurar un host Docker multi-pila: prueba qué es alcanzable, enlaza o divide lo que no debería ser, autentica los backends, y filtra los puertos publicados en la única cadena que Docker respeta.
La auditoría, como una lista de verificación
Ejecuta estos cuatro comandos en cualquier caja que poseas. Toman un minuto y te dicen más que cualquier diagrama de arquitectura:
# 1. ¿Qué está expuesto al mundo?
docker ps --format '{{.Names}}\t{{.Ports}}' | grep '0.0.0.0'
# 2. ¿Están tus reglas de firewall incluso en la ruta? (Las cadenas de Docker antes de ufw = no lo están)
sudo iptables -L FORWARD -n --line-numbers | head -8
# 3. ¿Qué puede alcanzar un contenedor comprometido en su propia red?
docker exec <container> python3 -c "import socket;s=socket.socket();s.settimeout(3);s.connect(('<svc>',<port>));print('alcanzado')"
# 4. ¿Ese servicio de respaldo pide una contraseña?
docker exec <container> python3 -c "
import socket;s=socket.socket();s.settimeout(4);s.connect(('redis',6379))
s.sendall(b'INFO server\r\n');print(s.recv(60))"
Lo que encontramos, y lo que costó arreglar
| Hallazgo | Severidad | Solución |
|---|---|---|
| El aislamiento entre redes funciona | ✅ ninguno | nada — Docker ya hace esto |
| Cada servicio en una red, mutuamente alcanzable | ⚠️ medio | dividir por zona de confianza, internal: true |
Redis respondiendo sin AUTH | 🔴 alto | una línea: --requirepass |
ufw deny no aplicado a puertos publicados | 🔴 alto | enlazar a 127.0.0.1, o regla DOCKER-USER |
Bases de datos de respaldo en 127.0.0.1 (Langfuse) | ✅ ninguno | ya correcto — copia este patrón |
Ninguna de las soluciones tomó más de una línea de YAML. La parte costosa fue saber qué línea — y la única manera de saberlo es sondear tu propia caja en lugar de confiar en el diagrama en tu cabeza.
Solución de problemas
Agregué una regla DOCKER-USER y nada cambió
Dos causas habituales. Primero, --dport debe ser el puerto del contenedor, no el publicado — el DNAT ya reescribió el destino para cuando DOCKER-USER ve el paquete (en nuestro caso 3000, no 3001). Segundo, las conexiones existentes no se ven afectadas: la coincidencia --ctstate NEW solo toca las nuevas, así que una pestaña de navegador ya abierta sigue funcionando.
Habilité UFW y perdí mi sesión SSH
sudo ufw allow 22/tcp antes de ufw enable, siempre. Si ya estás bloqueado, la mayoría de los proveedores te dan una consola serial/VNC — úsala para ejecutar ufw disable.
internal: true rompió la instalación de paquetes de mi contenedor
Eso es que está funcionando. Una red interna no tiene gateway, así que no hay salida en absoluto — sin pip install, sin DNS, sin llamar a una API externa. Los servicios de respaldo no deberían necesitar nada de eso; si un contenedor lo hace, también pertenece a la red de frontend.
Los servicios no pueden encontrarse después de que dividí las redes
El DNS de Compose solo resuelve nombres dentro de una red compartida. Si app y db están en diferentes redes sin nada en común, db no se resolverá. La app tiene que ser miembro de ambas, como en la Solución 2.
¿Qué sigue?
El endurecimiento de la red es la capa que evita que un contenedor comprometido se convierta en un host comprometido. Las siguientes capas, en el orden en que las haría: ejecutar contenedores como no-root (user: en compose) para que una ruptura comience con menos privilegios, y poner un proveedor de identidad frente a los puertos que debes exponer — una malla privada con
NetBird más autenticación es la combinación natural con todo lo anterior.
