La regla que escribiste, y la regla que escribió Docker
Dos programas editan el mismo firewall con supuestos distintos, y solo uno de ellos te avisa. ufw escribe sus reglas en la tabla filter, en la cadena INPUT — el camino que sigue un paquete cuando va dirigido al propio host. Docker escribe en la tabla nat y en FORWARD — el camino que sigue un paquete cuando va dirigido a otro sitio.
Sigue el rastro de un solo paquete y la brecha se vuelve evidente. Alguien en otro país abre una conexión hacia tu dirección en el puerto 5432. Llega, y lo primero que encuentra es nat PREROUTING, que lo envía a la cadena DOCKER de Docker. Allí, una regla DNAT reescribe el destino a 172.17.0.2:5432 — el contenedor. El paquete ya no va dirigido a tu servidor, así que el kernel lo enruta en lugar de entregarlo localmente: pasa por FORWARD, donde Docker ya ha instalado un ACCEPT para el tráfico con destino a un puerto publicado. En ningún punto de ese trayecto pasa el paquete por INPUT, que es la única cadena que ufw está filtrando. Tu regla nunca estuvo mal. Nunca llegó a ser consultada.
Esto también explica por qué el síntoma resulta tan desorientador. ufw status verbose sigue informando Default: deny (incoming), sin nada permitido salvo el 80, el 443 y tu puerto SSH, mientras un escáner en otro continente mantiene abierta una sesión con tu base de datos. Las dos afirmaciones son ciertas. El firewall hace exactamente lo que configuraste, con el tráfico que recibe, y el tráfico que importa no es el que le está llegando.
Nada de esto es un fallo, y Docker tampoco está siendo descuidado. Un motor de contenedores tiene que programar reglas de NAT y de reenvío o sus contenedores no podrían alcanzar la red en absoluto, y no puede adivinar con seguridad cuáles de las reglas del firewall de tu host estaban pensadas para aplicarse a ellos. Así que hace lo honesto: gestiona sus propias cadenas, y te entrega una cadena dedicada — DOCKER-USER, evaluada antes que todo lo demás en FORWARD — que promete no sobrescribir jamás. La brecha no es que exista el mecanismo. La brecha es que -p 5432:5432 se lee como "haz esto disponible" y significa "publica esto en cada dirección en la que responde esta máquina, saltándose el firewall al que le dedicaste una hora".
Cómo se ve un puerto expuesto desde fuera
Internet se entera más rápido de lo que la gente espera. Los rangos de los proveedores de hosting se escanean de forma continua y exhaustiva — no porque alguien se haya fijado en ti, sino por crawlers comerciales, proyectos de investigación y botnets oportunistas que barren cada dirección enrutable en cada puerto interesante y publican o venden los resultados. El intervalo entre docker compose up -d y la primera conexión no solicitada a un puerto de base de datos recién publicado se mide en horas. Nadie tuvo que adivinar tu hostname. Nadie tuvo que saber tu nombre. La dirección estaba dentro del rango.
Lo que encuentran depende por completo de lo que publicaste, y los casos habituales son sombríos. Un contenedor de PostgreSQL o MySQL arrancado desde el fragmento de inicio rápido de una imagen, con la contraseña trivial de ese fragmento todavía puesta. Una instancia de Elasticsearch o MongoDB que nunca se configuró para exigir autenticación, porque solo iba a ser alcanzable desde el contenedor de la aplicación. Un memcached que responde a cualquiera — lo cual no es solo una fuga de datos, sino un amplificador que se puede apuntar contra otra persona, convirtiendo tu servidor en un participante del tipo de inundación que describimos en nuestro runbook de la primera hora ante un DDoS. Un panel de administración o el dashboard de una cola de mensajes que daba por hecho una red privada. Un endpoint de métricas que va listando tranquilamente cada servicio interno, hostname y versión que ejecutas.
Merece la pena nombrar el daño de segundo orden, porque el daño de primer orden no es toda la factura. Una base de datos alcanzable desde internet no es solo legible — en la mayoría de los motores también es modificable, lo que significa que el intruso no necesita un exploit adicional para instalar persistencia, y a varios motores se les puede inducir a escribir archivos o ejecutar comandos en el host desde una sesión con privilegios. En una máquina que elegiste precisamente porque no lleva ninguna identidad asociada, un punto de apoyo sin autenticar es también un enlace de vuelta a todo lo demás que esa máquina toca: el destino de copia de seguridad al que puede llegar, las claves en sus variables de entorno, los demás contenedores en su bridge.
Lo incómodo es que nada de esto se anuncia. No hay ninguna línea de log que diga "tu firewall ha sido evadido". El servicio funciona, la aplicación se conecta, el sitio está en línea, y la única señal externa es un contador de conexiones que nadie está vigilando. La exposición la descubres tú, deliberadamente, en los próximos diez minutos — o la descubre otra persona, cuando le convenga.
SP·03Lee lo que de verdad estás publicando
Empieza por lo que el motor cree que está haciendo. docker ps imprime una columna PORTS, y la distinción que hay en ella es el tema entero de esta guía: 0.0.0.0:5432->5432/tcp significa cualquier dirección de la máquina, 127.0.0.1:5432->5432/tcp significa solo loopback, y un 5432/tcp a secas, sin flecha, significa que el puerto está expuesto a otros contenedores y no publicado en ningún sitio. Lee cada línea de esa columna en cada contenedor antes de cambiar nada.
Después mira los sockets con ss -tulpen. En una instalación por defecto verás docker-proxy reteniendo los puertos publicados, porque Docker todavía arranca un pequeño proceso en espacio de usuario por cada puerto publicado. Aquí está la trampa que le cuesta a la gente una tarde entera: si userland-proxy está desactivado en tu daemon — es un ajuste de tuning habitual, y algunas distribuciones lo traen así de fábrica — no hay absolutamente ningún socket a la escucha en el host. ss no muestra nada, lsof no muestra nada, y el puerto sigue completamente abierto, porque la regla DNAT del kernel hace el trabajo sin que ningún proceso necesite retener la dirección. Una salida silenciosa de ss no es prueba de que un puerto esté cerrado.
Así que lee las reglas mismas. iptables -t nat -S DOCKER lista una línea DNAT por cada puerto publicado, y cada línea lleva la respuesta que buscas: una regla con -d 127.0.0.1/32 es una publicación en loopback, y una regla sin restricción de destino se aplica a cualquier dirección que tenga la máquina. Haz lo mismo con ip6tables, porque las dos familias se configuran de forma independiente y una máquina puede estar cerrada en una y abierta en la otra.
Por último — y este es el único paso que de verdad demuestra algo — mira la máquina desde otro sitio. Todos los comandos anteriores se ejecutan en el host y heredan la propia visión que el host tiene de su red. El tráfico de loopback se salta las cadenas que importan, así que si curl 127.0.0.1:5432 tiene éxito no te dice nada sobre si un desconocido puede hacer lo mismo, y si falla te dice todavía menos. La prueba definitiva es un escaneo desde otra máquina, en otra red, en ambas familias de IP. Todo lo anterior es solo una hipótesis.
La vinculación a loopback, y la diferencia entre ports y expose
El arreglo útil más pequeño es de once caracteres. -p 127.0.0.1:5432:5432 le dice a Docker que escriba su regla DNAT con una restricción de destino, de modo que la reescritura solo se aplica al tráfico que ya era local. Un paquete remoto dirigido a tu dirección pública deja de coincidir, no se reenvía al contenedor, y por fin llega adonde siempre habías asumido que llegaría: INPUT, donde ufw lo deniega. En un archivo Compose lo mismo se escribe ports: ["127.0.0.1:5432:5432"], y las comillas importan — un valor sin comillas que contiene dos puntos es un accidente de parseo esperando a que ocurra.
Pero la mejor pregunta es por qué se publica el puerto siquiera. Los contenedores conectados a la misma red definida por el usuario se alcanzan entre sí directamente, por nombre de servicio, en el propio puerto del contenedor, sin publicar nada de ningún tipo. Tu aplicación no se conecta a 127.0.0.1:5432; se conecta a postgres:5432, resuelto por el DNS integrado de Docker a una dirección en el bridge privado. Una base de datos en ese esquema no necesita ninguna línea ports: — ni de loopback, ni de ningún tipo. El puerto publicado más seguro es el que borraste. Reserva ports: para el único servicio o los dos servicios que de verdad dan la cara al público, y deja que todo lo demás hable por la red privada.
Aquí es también donde se malinterpreta expose:. No publica nada y no abre nada; es documentación que deja constancia de en qué puerto escucha un servicio, y no tiene ningún efecto sobre el firewall en ninguna dirección. La gente lo añade esperando que sea la versión segura de ports:, y lo es — en el mismo sentido en que un comentario es la versión segura del código. Si quieres un servicio alcanzable solo por sus vecinos, no necesitas expose:; necesitas la ausencia de ports:.
Dos límites honestos de la vinculación a loopback. Primero, protege el límite del host, no el vecindario: los contenedores de la misma red bridge todavía pueden alcanzarse libremente entre sí, así que un contenedor de front-end comprometido tiene un camino directo hacia una base de datos que no publica nada. Separa los servicios en redes distintas y marca la de back-end como internal: true cuando el radio de impacto importe. Segundo, 127.0.0.1 es una dirección IPv4 y solo restringe IPv4; si el host tiene un /64 enrutable — todos los planes aquí incluyen uno —, razona sobre v6 por separado, y pruébalo por separado.
DOCKER-USER: la cadena que existe justo para esto
La vinculación a loopback arregla los contenedores que recuerdas. DOCKER-USER es cómo dejas de estar a un -p de distancia del próximo incidente. Docker la instala como el primer salto dentro de FORWARD, por delante de sus propias reglas de aceptación, y — a diferencia de todo lo demás en sus cadenas — deja su contenido intacto a través de reinicios, actualizaciones y contenedores nuevos. Es el lugar admitido para la política que el motor no puede inferir por sí solo: qué orígenes tienen permiso para alcanzar contenedores en este host.
El patrón son tres reglas en la interfaz pública, y el orden lo es todo. Primero, deja pasar el tráfico establecido y relacionado, para que las respuestas a las conexiones que abrieron tus contenedores sigan fluyendo. Segundo, deja pasar los orígenes que de verdad quieres admitir — una dirección de oficina, un host de monitorización, un servidor par. Tercero, descarta todo lo demás que llegue desde internet. Equivocarse con la regla de conntrack es el fallo clásico aquí: pon el DROP primero y toda conexión saliente de todos los contenedores morirá en el paquete de vuelta, lo cual se presenta como "Docker rompió el DNS y la instalación de paquetes" y hace que la gente busque en el sitio completamente equivocado.
Dos detalles operativos deciden si esto sobrevive al contacto con la realidad. Tiene que estar limitado a la interfaz pública por nombre — -i eth0, o lo que sea que reporte ip route get 1.1.1.1 en tu máquina — o también estarás descartando tráfico entre tus propios bridges. Y tiene que volver a aplicarse después de cada reinicio, después de que el daemon de Docker haya creado la cadena. Una unidad de systemd de tipo oneshot ordenada con After=docker.service es la forma fiable; guardarlo con netfilter-persistent también funciona, siempre que aceptes que restaurar todo un conjunto de reglas mientras Docker lo está reconstruyendo al mismo tiempo es una carrera que deberías verificar, no dar por hecha.
Luego la mitad de IPv6. Si ip6tables -S DOCKER-USER imprime una cadena, refleja en ella cada regla. Si da error, tu daemon directamente no gestiona las reglas de v6 — lo cual no te dice nada sobre si el tráfico v6 llega a tus contenedores, solo que Docker no lo está filtrando. No intentes deducir la respuesta por tu cuenta; los caminos difieren según la versión, la configuración del daemon y la distribución, y una conclusión equivocada pero sostenida con total confianza es peor que ninguna conclusión. Escanéate con nmap -6 y confía en el resultado.
Una cosa que no se debe hacer: recurrir a "iptables": false en /etc/docker/daemon.json. Eso hace que Docker deje de tocar el firewall, y también detiene la configuración del NAT de contenedores, el masquerading de salida y el aislamiento entre redes, que ya no los gestiona nadie. No has eliminado el problema, has heredado el trabajo — a mano, para cada contenedor, para siempre. En un único VPS, la opción honesta es dejar que Docker gestione sus propias cadenas y encargarte tú de DOCKER-USER.
La otra puerta: el socket, y lo que corre como root
Todo lo anterior trataba de paquetes que llegan. Esta sección trata de lo que se encuentran, y hay un elemento tan claramente peor que el resto que merece mencionarse aparte: montar /var/run/docker.sock dentro de un contenedor equivale a darle a ese contenedor privilegios de root sobre el host. No "algo parecido". Equivale. Cualquier cosa que pueda hablar con ese socket puede arrancar un nuevo contenedor privilegiado con el sistema de archivos del host montado dentro, y desde ahí leer cada clave, escribir cada archivo e instalar lo que quiera. Muchas imágenes convenientes lo piden — dashboards, actualizadores automáticos, proxies inversos con descubrimiento de servicios. Trata esa petición como una decisión de confiar en la imagen tanto como confías en tu propia shell de root, y ten el mismo cuidado con la API de Docker por TCP: un daemon sin autenticar en el puerto 2375 es la misma puerta, abierta a todo internet.
Más allá de eso, el runtime de contenedores te da cuatro reducciones baratas, y ninguna exige rediseñar nada. Ejecuta como usuario no root con user: "1000:1000", porque por defecto es root dentro del namespace, y ese es el punto de partida de todo escape. Define security_opt: ["no-new-privileges:true"], que impide que un binario setuid dentro de la imagen llegue a obtener más privilegios de los que tenía el proceso al arrancar. Elimina todas las capacidades con cap_drop: [ALL] y vuelve a añadir solo lo que el servicio demuestre necesitar — la mayoría de las aplicaciones web no necesitan ninguna. Monta el sistema de archivos raíz como read_only: true y dale un pequeño tmpfs para lo temporal, lo cual convierte "dejar cargada una web shell en el directorio de la aplicación" de un paso en un callejón sin salida.
La misma lógica se aplica a lo que se le entrega al contenedor. Los secretos pasados como variables de entorno son visibles para cualquier cosa que pueda leer el entorno del proceso, y se copian fielmente en la salida de docker inspect y en cualquier log o informe de fallo que vuelque la configuración; un archivo montado de solo lectura en una ruta conocida es menos cómodo y bastante menos propenso a filtrarse. Y un contenedor no necesita más red de la que exige su trabajo: un worker que solo habla con la base de datos no tiene ningún motivo para poder abrir conexiones hacia internet.
Si todo esto parece ir a contracorriente del comportamiento por defecto, es una lectura justa, y es el argumento a favor de Docker o Podman en modo rootless — donde el daemon y los contenedores se ejecutan como un usuario sin privilegios, un escape de contenedor te deja como ese usuario en lugar de como root, y los puertos publicados los retiene un proceso normal en espacio de usuario sobre el host, lo que significa que tus reglas de ufw se les aplican de la forma habitual. El compromiso es real: algunas capacidades, algunos drivers de almacenamiento y algunos trucos de red se comportan de forma distinta o directamente no funcionan. Merece la pena saber que esa opción existe y qué te ofrece a cambio, en lugar de descubrir después de un incidente que el comportamiento por defecto también era una elección.
SP·07Imágenes que no escribiste tú, en una máquina que no puedes permitirte perder
Una imagen de contenedor es el sistema de archivos de otra persona, corriendo en tu máquina, montado a partir de capas que no has leído. Eso no es un argumento contra usarlas — es un argumento a favor de saber cuáles estás ejecutando y qué antigüedad tienen. Los dos fallos que realmente ocurren en infraestructura pequeña son ambos mundanos: la imagen nunca fue de fiar, o era de fiar en marzo y nadie la ha reconstruido desde entonces.
Lo primero se resuelve sobre todo con disciplina sobre dónde vienen las imágenes. Prefiere repositorios oficiales o publicados por el propio fabricante antes que un fork conveniente con tres estrellas, y sospecha especialmente de las imágenes cuyo atractivo es que empaquetan seis servicios en una sola línea de YAML. Cuando importe de verdad, fija por digest en lugar de por tag: postgres:17 es un blanco móvil que puede cambiar bajo tus pies entre dos ejecuciones de docker compose pull, mientras que postgres@sha256:… es exactamente el sistema de archivos que probaste. Fijar por digest cambia correcciones automáticas por reproducibilidad, lo cual es el intercambio correcto cuando tienes el hábito de reconstruir, y el equivocado cuando no lo tienes.
Lo segundo es lo que muerde en silencio. Las actualizaciones desatendidas no tocan tus contenedores. Las actualizaciones de seguridad automáticas que configuraste en la primera hora parchean los paquetes del host y no tienen ninguna visibilidad sobre el espacio de usuario dentro de una imagen — así que una máquina que se declara completamente parcheada puede estar ejecutando un servidor web desde una imagen base con un año de vulnerabilidades sin corregir. Los contenedores no se actualizan, se reemplazan: haz pull, recréalos, y elimina lo que ya no esté referenciado, con una cadencia que de verdad mantengas. Una vez al mes, apuntado por escrito, gana a una intención perfecta.
Lo cual convierte al archivo Compose en el objeto más valioso del servidor. Es la única descripción completa de lo que es esa máquina, y reconstruir a partir de él debería ser una operación rutinaria y no un proyecto de arqueología — consérvalo en control de versiones, conserva los archivos de entorno junto a él, y consérvalos ambos en la copia de seguridad cifrada fuera de sitio que guarda tus datos. La prueba de un host de contenedores no es si está funcionando. Es si podrías levantarlo de nuevo, de forma idéntica, en un VPS nuevo en aproximadamente 15 min.
SP·08Logs, volúmenes, y el estado que sobrevive al contenedor
Los contenedores se anuncian como desechables, lo cual es cierto para el proceso y falso para todo lo que deja atrás. En un host de Docker se acumulan dos tipos de estado, y los dos sorprenden a la gente en el peor momento.
Lo primero son los logs. El driver json-file por defecto captura cada línea que tus contenedores escriben en stdout y stderr, y — a menos que le digas lo contrario — nunca los rota. Una aplicación habladora puede llenar un disco así en cuestión de semanas, y un disco lleno en un host de base de datos es un incidente en sí mismo. Fija un límite una sola vez, en /etc/docker/daemon.json, para que se aplique a todo lo que arranques a partir de ahí.
Hay una lectura de privacidad del mismo ajuste, y en un servidor que elegiste por privacidad es posiblemente la más importante. Un log de acceso sin rotar es un registro permanente y sin indexar de la dirección IP, el user agent y la ruta solicitada de cada visitante, sentado en un disco que no controlas físicamente. Tú no decidiste guardar eso. Lo decidió el valor por defecto en tu lugar. Decidirlo deliberadamente significa limitar la retención, y — mejor todavía — recortar lo que se escribe desde el principio: un formato de log en el proxy que omite o trunca las direcciones del cliente conserva el valor operativo y elimina el pasivo. Los datos que nunca escribiste no se pueden incautar, reclamar por orden judicial ni filtrar, que es el mismo razonamiento que lleva a la gente a mantener su nombre fuera de un servidor para empezar.
Lo segundo son los volúmenes, y el filo cortante es un flag. docker compose down detiene y elimina los contenedores y deja intactos los volúmenes con nombre; docker compose down -v los borra, permanentemente, con la misma tranquilidad. En esos volúmenes viven las bases de datos. Y también cualquier otra cosa que echarías de menos. Guarda tus datos en volúmenes con nombre en lugar de anónimos, ten claro qué comando estás escribiendo a las dos de la madrugada, y recuerda que docker system prune existe para recuperar espacio y está encantado de recuperar el tuyo.
Lo cual deja la copia de seguridad en sí, donde la abstracción de contenedor engaña una última vez. Copiar el directorio del volumen de una base de datos en marcha no es una copia de seguridad; es una copia de archivos que se estaban escribiendo mientras tú los leías, y se restaura como una base de datos corrupta justo cuando más la necesitas. Haz la copia de seguridad a través del motor — pg_dump, mysqldump, la API de snapshots propia del motor — y después cifra el resultado y envíalo a algún sitio al que tu servidor no pueda llegar. Esa última cláusula es la que importa cuando el intruso ya está dentro del contenedor: una copia de seguridad que tu host comprometido puede borrar tampoco es una copia de seguridad.
Paso a paso
-
01
Haz inventario de lo que la máquina está publicando ahora mismo
Antes de cambiar nada, anota el estado actual — lo vas a querer para comparar después. Lee la columna
PORTSde cada contenedor, y después lee las reglas NAT que Docker instaló realmente, en ambas familias de IP. Cualquier cosa impresa sin un destino127.0.0.1es alcanzable desde internet, diga lo que digaufw status.docker ps --format 'table {{.Names}}\t{{.Ports}}' ss -tulpen | grep -E 'docker|LISTEN' sudo iptables -t nat -S DOCKER # -d 127.0.0.1/32 = loopback only sudo ip6tables -t nat -S DOCKER # may not exist; that is an answer too sudo ufw status verbose # what you believed was trueAnota también el nombre de la interfaz pública ya que estás aquí — las reglas de firewall de más adelante lo necesitan, y no es
eth0en todas las imágenes.ip route get 1.1.1.1 | awk '{print $5; exit}' -
02
Escanéate desde otro sitio
El host no puede auditarse a sí mismo: el tráfico de loopback nunca cruza las cadenas que estás intentando probar. Ejecuta esto desde otra máquina, en otra red — un segundo VPS desde $8.00/mes en otra región es el banco de pruebas honesto, y además sigue siendo útil después. Escanea las dos familias, porque se configuran por separado.
# from ANOTHER machine, against your server nmap -Pn -sS -p- --min-rate 1000 203.0.113.10 nmap -Pn -6 -p 22,80,443,3306,5432,6379,9200,27017 2001:db8::1 # no nmap? one port at a time is enough to settle an argument nc -zv 203.0.113.10 5432
Todo puerto abierto que no sea el 80, el 443 o tu puerto SSH es un hallazgo. Guarda la salida; es la mitad "antes" de la prueba que vas a querer tener al final.
-
03
Deja de publicar lo que no necesita ser público
Ahora arréglalo en el origen, en el archivo Compose. Los servicios de back-end pasan a una red privada y pierden del todo su línea
ports:— la aplicación los alcanza por nombre de servicio. Todo lo que deba ser alcanzable desde el propio host recibe un bind explícito a loopback en lugar de un puerto a secas. Marca la red de back-end comointernalpara que nada en ella pueda hablar con internet sin que se lo pidas.services: db: image: postgres:17 # ports: ["5432:5432"] # deleted: the app reaches it as db:5432 networks: [back] volumes: [dbdata:/var/lib/postgresql/data] app: image: myapp:1.4 environment: DATABASE_URL: postgres://app@db:5432/app ports: ["127.0.0.1:8080:8080"] # loopback only; proxy sits in front networks: [back, front] networks: front: {} back: internal: true volumes: dbdata: {}Vuelve a crear el stack y confirma que las reglas NAT cambiaron de forma — la línea DNAT de la aplicación ahora debería llevar un destino de loopback, y la base de datos no debería tener ninguna línea en absoluto.
docker compose up -d docker ps --format 'table {{.Names}}\t{{.Ports}}' sudo iptables -t nat -S DOCKER -
04
Pon exactamente una cosa delante del público
Con todo en loopback, un único proxy inverso se convierte en la única superficie pública — el único sitio donde se termina TLS, el único sitio donde se llega a ver una dirección de cliente, y el único sitio donde se decide un formato de log. Ejecútalo en el host o en un contenedor que publique el 80 y el 443 y nada más; si corre en un contenedor, se une a la red
fronty hace proxy hacia nombres de servicio en lugar de hacia loopback.# in the http{} block of /etc/nginx/nginx.conf — no client address recorded log_format privacy '- - [$time_local] "$request" $status $body_bytes_sent'; # /etc/nginx/conf.d/app.conf (proxy on the host) server { listen 443 ssl; listen [::]:443 ssl; server_name example.com; access_log /var/log/nginx/app.log privacy; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } }Si el objetivo del ejercicio es que nadie llegue a saber dónde está la máquina, el proxy pertenece a otra máquina completamente distinta — esa es la configuración de ocultar tu IP de origen tras un proxy inverso, y combina bien con todo lo de aquí.
-
05
Cierra la brecha con DOCKER-USER, en ambas familias
Los binds a loopback arreglan los contenedores de hoy; esto arregla los que todavía no has escrito. Tres reglas en la interfaz pública: conntrack primero, para que la salida de los contenedores siga funcionando, luego tus orígenes permitidos, y después un drop. Prueba la conectividad saliente de un contenedor justo después de aplicar los cambios — ese es el paso que la gente hace al revés.
PUB=$(ip route get 1.1.1.1 | awk '{print $5; exit}') sudo iptables -I DOCKER-USER 1 -i "$PUB" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN sudo iptables -I DOCKER-USER 2 -i "$PUB" -s 203.0.113.7/32 -j RETURN # your admin address sudo iptables -I DOCKER-USER 3 -i "$PUB" -j DROP sudo iptables -S DOCKER-USER docker run --rm alpine sh -c 'apk update' >/dev/null && echo 'egress OK'Replícalo para IPv6 si el daemon gestiona reglas v6, y después haz que sobreviva a un reinicio con una unidad que se ejecute después de que Docker haya reconstruido sus cadenas.
sudo ip6tables -S DOCKER-USER || echo 'no v6 chain: verify v6 reachability by scanning' # /etc/systemd/system/docker-user-rules.service [Unit] After=docker.service Requires=docker.service [Service] Type=oneshot ExecStart=/usr/local/sbin/docker-user-rules.sh RemainAfterExit=yes [Install] WantedBy=multi-user.target
-
06
Reduce privilegios dentro de los contenedores, y limita los logs
Reduce lo que puede hacer una imagen comprometida, y evita que el host se llene con un registro que nadie eligió conservar. Los cuatro ajustes del contenedor no cuestan nada en un servicio web normal; el límite de logs se aplica a todo contenedor que arranque después de recargar el daemon.
services: app: image: myapp:1.4 user: "1000:1000" read_only: true tmpfs: [/tmp] cap_drop: [ALL] security_opt: ["no-new-privileges:true"] # never: volumes: ["/var/run/docker.sock:/var/run/docker.sock"]# /etc/docker/daemon.json { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }sudo systemctl reload docker docker inspect --format '{{.HostConfig.LogConfig}}' app -
07
Verifica desde fuera, y después escribe el runbook
Reinicia a propósito, mientras nada depende de la respuesta, y demuestra que el estado que construiste es el estado que vuelve. Después repite el escaneo externo del paso dos y compáralo con la salida que guardaste — ese diff es el entregable, no los comandos.
sudo reboot # after it returns: sudo iptables -S DOCKER-USER # rules reapplied? docker ps --format 'table {{.Names}}\t{{.Ports}}' # from ANOTHER machine again: nmap -Pn -p- 203.0.113.10 nmap -Pn -6 -p- 2001:db8::1Apunta cinco líneas en algún sitio donde las vayas a encontrar: a qué interfaz se vinculan las reglas, dónde vive la unidad, qué servicios son deliberadamente públicos, cuándo reconstruiste las imágenes por última vez, y cómo restaurar los volúmenes. Un host de contenedores vale exactamente lo que valga la descripción que te permite reconstruirlo después del peor día.


