14

Segmentación de redes con Docker

Vistas: 1
Segmentación de redes con Docker

En el capítulo anterior montaste WireGuard. Ahora accedes a tu red local desde cualquier lugar sin exponer servicios de administración a internet. Buen avance. Pero hay un problema. Tus contenedores Docker siguen hablando entre sí como les da la gana. Todos en la misma red. Todos pueden verse. Todos pueden conectarse a todos. ¿Te parece seguro que un contenedor de WordPress pueda hablar directamente con Redis? ¿O que un contenedor de Nextcloud pueda hacer peticiones a la base de datos de otro servicio completamente distinto? La respuesta es no. Y si tu intuición te dice que no, tienes razón.

Docker por defecto pone todos tus contenedores en una misma red bridge. Eso significa que cualquier contenedor puede alcanzar a cualquier otro por su IP interna. No hay aislamiento. No hay segmentación. Es como poner a todos tus vecinos en el mismo cuarto sin puertas.

En este capítulo vas a aprender a diseñar redes Docker que aíslen tus servicios por función. Redes públicas para lo que necesita estar expuesto. Redes privadas para lo que no debe salir al exterior. Y redes de datos para lo que solo debe ser accesible por unos pocos.

El problema: todos los contenedores en la misma red

Cuando ejecutas docker-compose up sin definir redes, Docker crea una red por defecto para ese archivo. Todos los servicios del mismo docker-compose.yml se conectan a esa red y pueden comunicarse entre sí por el nombre del servicio.

Esto es cómodo. Muy cómodo. No tienes que pensar en redes. Cada contenedor encuentra a los demás por su nombre. Pero es un desastre de seguridad.

Imagina que tienes esta configuración:

services:
  web:
    image: nginx
    ports:
      - "80:80"

  db:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: secreta

  redis:
    image: redis:7

Los tres contenedores están en la misma red. WordPress puede hablar con PostgreSQL directamente. También puede hablar con Redis. ¿Para qué necesita WordPress acceso a Redis? Para nada. Pero lo tiene.

Ahora imagina que WordPress tiene una vulnerabilidad. Un atacante la explota y obtiene una shell dentro del contenedor. Desde ahí, hace un escaneo de la red interna:

nmap -sn 172.17.0.0/16

Descubre PostgreSQL en 172.17.0.3 y Redis en 172.17.0.4. Intenta conectarse a PostgreSQL con contraseñas por defecto. Si no cambiaste la contraseña, o si usaste una débil, el atacante tiene tu base de datos.

Todo porque todos los contenedores estaban en la misma red.

Este es el problema fundamental. Docker te da comodidad a costa de seguridad. Y en un entorno self-hosted, la comodidad no es excusa.

Redes Docker: tipos y usos

Docker tiene varios tipos de redes. Cada una sirve para un propósito distinto. Vamos a ver las que te interesan para segmentar tu servidor.

Bridge

La red bridge es la red por defecto de Docker. Crea un puente virtual en el host. Los contenedores conectados a un bridge pueden comunicarse entre sí y con el host. No pueden comunicarse con contenedores de otros bridges a menos que los conectes explícitamente.

Cuando ejecutas docker-compose up sin definir redes, Docker crea un bridge automático para ese proyecto. Cada proyecto tiene su propio bridge. Los contenedores del proyecto A no pueden hablar con los del proyecto B.

Esto ya es un avance. Pero dentro del mismo proyecto, todos los contenedores siguen viéndose.

Para segmentar bien, necesitas crear múltiples bridges en el mismo proyecto y asignar cada servicio a la red que le corresponda.

Overlay

La red overlay conecta contenedores en múltiples hosts Docker. Es la red que usas cuando tienes un cluster Docker Swarm. Los contenedores en diferentes máquinas se comunican como si estuvieran en la misma red local.

Para un servidor self-hosted típico con un solo nodo, no necesitas overlay. Bridge es suficiente.

Pero si tienes varios servidores y usas Docker Swarm, overlay te permite segmentar a través de múltiples máquinas. Es el mismo concepto, pero distribuido.

Macvlan

La red macvlan asigna una dirección MAC real a cada contenedor. El contenedor aparece en tu red local como un dispositivo físico más, con su propia IP.

Esto es útil cuando quieres que un contenedor sea directamente accesible en tu red local sin pasar por NAT. Por ejemplo, un servidor DHCP o un DNS.

Para la segmentación de servicios web, macvlan no es necesaria. Te conviene mantener los contenedores en redes bridge aisladas y solo exponer lo necesario a través de Traefik.

¿Cuál usas?

Para el 99% de los casos en un servidor self-hosted, usas redes bridge. Creas varias redes bridge personalizadas y conectas cada contenedor solo a las que necesita.

Overlay la usas si tienes Docker Swarm. Macvlan la usas en casos muy concretos donde el contenedor necesita ser un ciudadano de pleno derecho en tu red local.

El resto del capítulo se centra en redes bridge. Son las que realmente necesitas.

Diseño de redes: pública, privada y de datos

Para segmentar bien, necesitas tres tipos de redes.

Red pública (traefik_public)

Esta red conecta Traefik con los servicios que deben ser accesibles desde internet. Traefik está en esta red. Cada servicio que expones con Traefik también está en esta red.

Nada más debería estar en esta red. Ni bases de datos, ni Redis, ni servicios internos.

networks:
  traefik_public:
    external: true

La declaras como externa porque Traefik ya la creó cuando lo configuraste en el capítulo 5. Si no la creaste explícitamente, la creas ahora:

docker network create traefik_public

Red privada (internal_backend)

Esta red conecta los servicios que se comunican entre sí pero no deben ser accesibles desde fuera. Tu aplicación web y tu API están en esta red. Se hablan entre ellas aquí.

Traefik no está en esta red. Los contenedores de esta red solo son accesibles desde otros contenedores de la misma red, o desde contenedores que conectes explícitamente a ella.

networks:
  internal_backend:
    driver: bridge
    internal: true

El parámetro internal: true es clave. Hace que la red no tenga acceso al exterior. Los contenedores en esta red solo pueden hablar entre sí. No tienen salida a internet ni al host.

¿Por qué querrías esto? Porque si un contenedor en esta red se ve comprometido, no puede hacer peticiones a internet. No puede contactar con un servidor de comando y control. No puede descargar malware. Está aislado.

Usa internal: true con cuidado. Si tu base de datos necesita hacer peticiones externas (por ejemplo, para réplicas o backups externos), no puedes ponerla en una red interna. Pero para la mayoría de los casos, es una capa de seguridad enorme.

Red de base de datos (db_network)

Esta red es la más restrictiva. Solo contiene las bases de datos y los servicios que necesitan acceder a ellas.

Ni Traefik, ni la aplicación web, ni nada más debería estar en esta red. Solo el servicio que necesita la base de datos y la base de datos misma.

networks:
  db_network:
    driver: bridge
    internal: true

¿Por qué separar la base de datos de la red privada del backend? Porque quieres minimizar el alcance de un posible ataque.

Si un atacante compromete tu aplicación web, tiene acceso a la red privada del backend. Desde ahí puede escanear, moverse lateralmente, intentar explotar otros servicios. Si la base de datos está en su propia red, el atacante necesita un salto más para alcanzarla.

Es el principio de defensa en profundidad. Varias capas. Cada capa añade fricción al atacante.

El diseño completo

Visualízalo así:

internet
    |
    |
Traefik (red pública)
    |
    |
Aplicación web (red pública + red privada)
    |
    |
API / Backend (red privada + red de datos)
    |
    |
Base de datos (red de datos, aislada)

Cada servicio tiene acceso solo a lo que necesita. Nada más.

Traefik ve la aplicación web porque están en la red pública. La aplicación web ve la API porque están en la red privada. La API ve la base de datos porque están en la red de datos.

Pero Traefik no ve la API ni la base de datos. La aplicación web no ve la base de datos. La API no ve internet.

Cada uno en su capa. Cada capa con su red.

Conectar contenedores a múltiples redes

Un contenedor puede estar en varias redes a la vez. De hecho, ese es el truco para segmentar bien.

Tu aplicación web necesita estar en la red pública (para que Traefik le pase tráfico) y en la red privada (para hablar con la API). Pero no necesita estar en la red de datos.

Tu API necesita estar en la red privada (para recibir peticiones de la web) y en la red de datos (para consultar la base de datos). Pero no necesita estar en la red pública.

Tu base de datos solo necesita estar en la red de datos. Punto.

En Docker Compose, conectas un servicio a múltiples redes así:

services:
  web:
    image: nginx
    networks:
      - traefik_public
      - internal_backend

  api:
    image: mi-api
    networks:
      - internal_backend
      - db_network

  db:
    image: postgres:15
    networks:
      - db_network

networks:
  traefik_public:
    external: true
  internal_backend:
    driver: bridge
    internal: true
  db_network:
    driver: bridge
    internal: true

Fíjate en el patrón. Cada servicio solo declara las redes que necesita. No pongas db_network en la web. No pongas traefik_public en la API. Sé preciso.

Cada red adicional que conectas a un contenedor es una superficie de ataque potencial. Si el contenedor se ve comprometido, el atacante tiene acceso a todas las redes a las que ese contenedor está conectado. Menos redes, menos riesgo.

Exponer servicios solo en redes internas

Hay servicios que nunca, bajo ninguna circunstancia, deberían tener una interfaz accesible desde fuera.

Lista negra de servicios que no deben salir a internet:

  • PostgreSQL, MySQL, MariaDB.
  • Redis, Memcached.
  • RabbitMQ, Kafka.
  • Cualquier base de datos o cola de mensajes.
  • APIs internas que solo consumen otros servicios.
  • Paneles de administración de bases de datos (phpMyAdmin, Adminer).
  • Servicios de monitorización internos.

Todos estos servicios deberían estar en redes internas, sin puertos expuestos en el host.

Mira este ejemplo de lo que NO debes hacer:

services:
  db:
    image: postgres:15
    ports:
      - "5432:5432"  # ERROR: esto expone PostgreSQL a la red del host

Esa línea ports expone PostgreSQL en 0.0.0.0:5432. Cualquier dispositivo en tu red local puede conectarse. Si tu firewall tiene abierto ese puerto, cualquier persona en internet puede intentarlo.

Nunca expongas puertos de bases de datos. Jamás.

Si necesitas acceder a la base de datos desde tu máquina para hacer consultas o migraciones, hazlo a través de la VPN de WireGuard que configuraste en el capítulo anterior. O ejecuta un cliente dentro de la red Docker.

La forma correcta:

services:
  db:
    image: postgres:15
    expose:
      - "5432"
    networks:
      - db_network

expose documenta el puerto pero no lo abre al exterior. Solo los contenedores en la misma red Docker pueden alcanzarlo.

Traefik en la red pública con acceso a servicios internos

Traefik es el caso especial. Necesita estar en la red pública para recibir tráfico de internet. Pero también necesita poder reenviar ese tráfico a los servicios que gestiona.

La cuestión es cómo conectar Traefik a los servicios sin exponerlos.

La respuesta: Traefik solo necesita estar en la red pública. Los servicios que Traefik gestiona también deben estar en la red pública (o al menos en una red donde Traefik pueda alcanzarlos).

Pero espera. Dijiste que la API no debe estar en la red pública. ¿Cómo hace Traefik para reenviar tráfico a la API si no están en la misma red?

Aquí tienes dos enfoques.

Enfoque 1: proxy inverso en la red pública, servicios en su red

Traefik está en la red pública. Tu aplicación web está en la red pública y en la red privada. Traefik habla con la aplicación web a través de la red pública. La aplicación web habla con la API a través de la red privada.

Traefik (pública) --> Web (pública + privada) --> API (privada + datos) --> DB (datos)

Este es el diseño más limpio. Traefik solo ve la web. La web ve la API pero no ve la DB. La API ve la DB. Cada uno ve solo lo que necesita.

Traefik no necesita acceso directo a la API ni a la base de datos. Traefik solo necesita reenviar tráfico HTTP a la web. La web se encarga del resto.

Enfoque 2: Traefik como proxy con múltiples redes

A veces necesitas que Traefik enrute directamente a servicios que no están diseñados para ser expuestos por separado. Por ejemplo, si tienes un servicio que no tiene su propio frontend y necesitas que Traefik le pase tráfico directamente.

En ese caso, Traefik necesita estar en la red de ese servicio. Pero sigues sin querer exponer ese servicio a internet directamente.

La solución: Traefik está en la red pública y también en las redes de los servicios a los que necesita enrutar. Pero los servicios no están en la red pública.

services:
  traefik:
    image: traefik:v3
    networks:
      - traefik_public

  api:
    image: mi-api
    networks:
      - internal_backend

  admin-panel:
    image: admin-panel
    networks:
      - internal_backend

networks:
  traefik_public:
    external: true
  internal_backend:
    driver: bridge
    internal: true

En este diseño, Traefik no puede alcanzar la API ni el panel de administración porque están en otra red. Necesitas conectar Traefik también a internal_backend.

services:
  traefik:
    image: traefik:v3
    networks:
      - traefik_public
      - internal_backend

Ahora Traefik puede enrutar tráfico a la API y al panel de administración. Pero la API y el panel siguen sin estar en la red pública. No son accesibles desde internet directamente. Solo a través de Traefik.

¿Y la base de datos? Sigue en su propia red, inalcanzable para Traefik.

Este enfoque es menos restrictivo que el primero porque Traefik tiene acceso a la red privada. Pero sigue siendo seguro porque Traefik es el único punto de entrada y está protegido con middlewares de seguridad.

Mi recomendación: usa el enfoque 1 siempre que puedas. El enfoque 2 solo cuando necesites enrutar directamente a servicios internos sin frontend propio.

Aislar bases de datos: sin interfaz externa

Las bases de datos son el tesoro de tu servidor. Ahí están los datos de tus usuarios, tus contraseñas (hasheadas, espero), tu contenido, tu configuración.

Una base de datos expuesta es una catástrofe esperando ocurrir.

Reglas de oro para bases de datos en Docker

Primera regla: nunca expongas puertos de base de datos con ports. Usa siempre expose para documentar el puerto interno.

Segunda regla: cada base de datos va en su propia red aislada. No compartas la red de base de datos entre servicios que no necesiten esa base de datos en concreto.

Tercera regla: no pongas la base de datos en la misma red que Traefik ni que servicios públicos.

Cuarta regla: usa contraseñas fuertes incluso en redes internas. Una red aislada no es excusa para usar password: password.

Quinta regla: si puedes, usa internal: true en la red de la base de datos. Así los contenedores de esa red no tienen acceso a internet.

Ejemplo de base de datos aislada

services:
  app:
    image: mi-app
    networks:
      - traefik_public
      - internal_backend

  api:
    image: mi-api
    networks:
      - internal_backend
      - db_network
    depends_on:
      - db

  db:
    image: postgres:15
    environment:
      POSTGRES_DB: miapp
      POSTGRES_USER: miapp
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    expose:
      - "5432"
    networks:
      - db_network
    restart: unless-stopped

networks:
  traefik_public:
    external: true
  internal_backend:
    driver: bridge
    internal: true
  db_network:
    driver: bridge
    internal: true

volumes:
  pgdata:

Fíjate en los detalles:

  • db solo tiene la red db_network. No tiene ninguna otra.
  • db usa expose: 5432, no ports: 5432:5432.
  • db_network tiene internal: true. La base de datos no tiene salida a internet.
  • La contraseña viene de una variable de entorno ${DB_PASSWORD}, no está hardcodeada.
  • Solo api está en db_network. La app no puede hablar directamente con la base de datos.

Si un atacante compromete la app, puede hablar con la api a través de internal_backend. Pero no puede alcanzar la base de datos porque no está en db_network. Necesita comprometer también la api para llegar a la base de datos.

Eso es defensa en profundidad.

Seguridad por defecto: no exponer puertos innecesarios

Hay una tendencia a añadir ports a todos los servicios en Docker Compose. «Por si acaso». «Para hacer pruebas». «Por si necesito acceder directamente».

Cada puerto expuesto es una puerta abierta. Cada puerto que no necesitas, ciérralo.

¿Puerto expuesto o no?

Pregúntate: ¿necesito acceder a este servicio desde fuera de Docker?

Si la respuesta es no, no expongas el puerto.

¿Quién necesita acceder a este servicio?

  • Si solo lo necesita otro contenedor, no expongas el puerto. La red Docker es suficiente.
  • Si lo necesitas tú desde tu máquina, usa VPN o un túnel SSH. No expongas el puerto.
  • Si lo necesita toda la humanidad, usa Traefik con autenticación. Y aún así, valora si realmente debe ser público.

Alternativas a exponer puertos

Necesitas hacer una consulta a PostgreSQL desde tu máquina. No expongas el puerto 5432. En su lugar:

Opción A: conéctate a la VPN (WireGuard) y ejecuta psql desde un contenedor temporal en la misma red:

docker run -it --rm --network db_network postgres:15 psql -h db -U miapp

Opción B: usa un túnel SSH si no tienes VPN:

ssh -L 5432:db:5432 usuario@servidor

Opción C: instala un cliente de base de datos en el propio servidor y conéctate a través del socket Docker.

Ninguna de estas opciones expone la base de datos a internet. Todas son seguras si las usas correctamente.

La regla de oro

No pongas ports en un servicio a menos que sepas exactamente por qué lo necesitas y hayas evaluado el riesgo.

Para servicios de base de datos, la regla es absoluta: nunca uses ports.

Para servicios de aplicación, usa ports solo si el servicio no va a través de Traefik. Y si va a través de Traefik, no necesitas ports. Traefik se conecta a través de la red Docker interna.

# MAL: expones el servicio directamente y además usas Traefik
services:
  web:
    image: nginx
    ports:
      - "8080:80"  # No necesitas esto si usas Traefik
    networks:
      - traefik_public

# BIEN: solo red Docker, Traefik hace de puerta de entrada
services:
  web:
    image: nginx
    expose:
      - "80"
    networks:
      - traefik_public

La diferencia es sutil pero importante. Con ports, el servicio es accesible en localhost:8080 y en la IP del servidor. Con expose, solo es accesible a través de la red Docker. Traefik lo alcanza por la red Docker, pero nadie más.

Ejemplo práctico: stack de aplicación web con base de datos aislada

Vamos a montar un ejemplo completo. Una aplicación web típica con frontend, API, Redis para caché y PostgreSQL como base de datos.

Cada servicio en su red. Cada red con su propósito.

# docker-compose.yml
services:
  traefik:
    image: traefik:v3
    container_name: traefik
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - traefik_public
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/traefik.yml:/traefik.yml:ro
      - ./traefik/config:/config:ro
      - ./traefik/certs:/certs
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik.rule=Host(`traefik.tudominio.com`)"
      - "traefik.http.routers.traefik.entrypoints=websecure"
      - "traefik.http.routers.traefik.tls=true"
      - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik.service=api@internal"
      - "traefik.http.routers.traefik.middlewares=auth@file"

  frontend:
    image: nginx:alpine
    container_name: frontend
    restart: unless-stopped
    expose:
      - "80"
    networks:
      - traefik_public
      - frontend_backend
    volumes:
      - ./frontend/build:/usr/share/nginx/html:ro
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.frontend.rule=Host(`app.tudominio.com`)"
      - "traefik.http.routers.frontend.entrypoints=websecure"
      - "traefik.http.routers.frontend.tls=true"
      - "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
      - "traefik.http.services.frontend.loadbalancer.server.port=80"
    depends_on:
      - api

  api:
    build: ./api
    container_name: api
    restart: unless-stopped
    expose:
      - "3000"
    networks:
      - frontend_backend
      - api_cache
      - api_db
    environment:
      DATABASE_URL: postgres://miapp:${DB_PASSWORD}@db:5432/miapp
      REDIS_URL: redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  redis:
    image: redis:7-alpine
    container_name: redis
    restart: unless-stopped
    expose:
      - "6379"
    networks:
      - api_cache
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

  db:
    image: postgres:15-alpine
    container_name: db
    restart: unless-stopped
    expose:
      - "5432"
    networks:
      - api_db
    environment:
      POSTGRES_DB: miapp
      POSTGRES_USER: miapp
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U miapp"]
      interval: 10s
      timeout: 5s
      retries: 5

networks:
  traefik_public:
    external: true

  frontend_backend:
    driver: bridge
    internal: true

  api_cache:
    driver: bridge
    internal: true

  api_db:
    driver: bridge
    internal: true

volumes:
  redis_data:
  pgdata:

Analicemos cada parte.

Redes en este stack

Tienes cuatro redes.

traefik_public: conecta Traefik con el frontend. Traefik recibe peticiones de internet y las reenvía al frontend. Nadie más está en esta red salvo Traefik y frontend.

frontend_backend: conecta el frontend con la API. El frontend hace peticiones a la API a través de esta red. Es interna, no tiene acceso a internet.

api_cache: conecta la API con Redis. Solo ellos dos están en esta red. Redis no tiene otra red.

api_db: conecta la API con PostgreSQL. Solo ellos dos. PostgreSQL no tiene otra red.

Flujo de tráfico

Cuando alguien visita app.tudominio.com:

  1. El tráfico llega a Traefik por el puerto 443.
  2. Traefik reenvía la petición al frontend a través de traefik_public.
  3. El frontend sirve el HTML estático. El JavaScript en el navegador hace llamadas a la API.
  4. El frontend reenvía las peticiones de la API a través de frontend_backend.
  5. La API procesa la petición. Si necesita datos, consulta PostgreSQL a través de api_db. Si necesita caché, consulta Redis a través de api_cache.
  6. La respuesta vuelve por el mismo camino.

Aislamiento aplicado

  • Traefik solo ve el frontend. No ve la API, ni Redis, ni PostgreSQL.
  • El frontend solo ve Traefik y la API. No ve Redis ni PostgreSQL.
  • La API ve el frontend, Redis y PostgreSQL. Pero no ve Traefik ni internet.
  • Redis solo ve la API. No ve nada más.
  • PostgreSQL solo ve la API. No ve nada más.

Si alguien compromete el frontend, tiene acceso a la API. Pero no tiene acceso directo a Redis ni a PostgreSQL. Para llegar a los datos, necesita comprometer también la API.

Si alguien compromete Traefik, solo ve el frontend. No puede escalar a la API ni a los datos.

Si alguien compromete Redis, solo ve la API. No puede acceder a PostgreSQL ni al frontend.

Cada contenedor tiene el mínimo acceso necesario para funcionar. Nada más.

Verificación: comprobar conectividad

Has montado el stack. Ahora toca verificar que el aislamiento funciona.

Verificar redes creadas

docker network ls

Deberías ver tus redes:

NETWORK ID     NAME                DRIVER    SCOPE
abc123...      traefik_public      bridge    local
def456...      frontend_backend    bridge    local
ghi789...      api_cache           bridge    local
jkl012...      api_db              bridge    local

Verificar conectividad correcta

Entra en el contenedor de la API y comprueba que puede alcanzar Redis y PostgreSQL:

docker exec -it api sh

# Probar conexión a Redis
nc -zv redis 6379
# Respuesta esperada: Connected to redis 6379

# Probar conexión a PostgreSQL
nc -zv db 5432
# Respuesta esperada: Connected to db 5432

# Probar conexión al frontend
nc -zv frontend 80
# Respuesta esperada: Connected to frontend 80

Todo esto debería funcionar porque la API está en las redes necesarias.

Verificar aislamiento

Ahora comprueba que lo que NO debe funcionar, realmente no funciona.

Entra en el frontend:

docker exec -it frontend sh

# La API debería ser accesible (misma red frontend_backend)
nc -zv api 3000
# Respuesta esperada: Connected to api 3000

# Redis NO debería ser accesible
nc -zv redis 6379
# Respuesta esperada: connection refused o timeout

# PostgreSQL NO debería ser accesible
nc -zv db 5432
# Respuesta esperada: connection refused o timeout

Entra en Redis:

docker exec -it redis sh

# PostgreSQL NO debería ser accesible
nc -zv db 5432
# Respuesta esperada: connection refused o timeout

# La API tampoco debería ser accesible... espera
nc -zv api 3000
# Respuesta esperada: connection refused o timeout

Redis solo está en api_cache. La API también está en api_cache. ¿Por qué Redis no puede alcanzar la API? Porque Redis no tiene la API en su red… No, espera. Sí debería poder. Vamos a aclarar.

Si dos contenedores están en la misma red, pueden comunicarse. Redis y la API están en api_cache. La API puede alcanzar Redis y Redis puede alcanzar la API. Pero Redis no puede alcanzar PostgreSQL porque están en redes diferentes.

Comprueba que Redis NO puede alcanzar PostgreSQL. Eso es lo importante.

docker exec -it redis sh
nc -zv db 5432
# Debería fallar

Verificar desde fuera de Docker

Desde el host (tu servidor), intenta conectar a los puertos de los servicios internos:

# Intentar conectar a PostgreSQL
nc -zv localhost 5432
# Debería fallar porque PostgreSQL no tiene puerto expuesto

# Intentar conectar a Redis
nc -zv localhost 6379
# Debería fallar por la misma razón

Si tienes éxito en alguna de estas conexiones, has expuesto un puerto que no deberías. Revisa tu docker-compose.yml.

Verificar el aislamiento de red interna

Las redes con internal: true no tienen acceso a internet. Compruébalo:

docker exec -it api sh
ping -c 1 8.8.8.8
# Debería fallar (no hay salida a internet)
docker exec -it db sh
ping -c 1 8.8.8.8
# También debería fallar

Si tienes conectividad a internet desde estos contenedores, la red no está correctamente aislada. Revisa que has puesto internal: true en las redes.

Verificar conectividad de Traefik

Desde Traefik, comprueba que puede alcanzar el frontend:

docker exec -it traefik sh
wget -qO- http://frontend
# Debería recibir el HTML del frontend

Y que NO puede alcanzar la API, Redis ni PostgreSQL:

docker exec -it traefik sh
nc -zv api 3000
# Debería fallar

Si Traefik puede alcanzar la API directamente, tienes un problema de segmentación. Traefik solo debería ver el frontend.

Automatizar las pruebas

Puedes crear un script para verificar el aislamiento después de cada despliegue:

#!/bin/bash
# test-isolation.sh

echo "Comprobando aislamiento de red..."

# La API debe poder alcanzar Redis y DB
docker exec api nc -zv redis 6379 && echo "OK: API -> Redis" || echo "FAIL: API -> Redis"
docker exec api nc -zv db 5432 && echo "OK: API -> DB" || echo "FAIL: API -> DB"

# La API no debe tener salida a internet
docker exec api ping -c 1 -W 2 8.8.8.8 && echo "FAIL: API -> Internet" || echo "OK: API no tiene internet"

# Redis no debe alcanzar DB
docker exec redis nc -zv db 5432 && echo "FAIL: Redis -> DB" || echo "OK: Redis no ve DB"

# Frontend no debe alcanzar DB ni Redis
docker exec frontend nc -zv db 5432 && echo "FAIL: Frontend -> DB" || echo "OK: Frontend no ve DB"
docker exec frontend nc -zv redis 6379 && echo "FAIL: Frontend -> Redis" || echo "OK: Frontend no ve Redis"

# DB no debe tener salida a internet
docker exec db ping -c 1 -W 2 8.8.8.8 && echo "FAIL: DB -> Internet" || echo "OK: DB no tiene internet"

echo "Verificación completada."

Este script lo ejecutas después de cada docker-compose up -d. Si algo falla, sabes que la segmentación no es correcta.

Buenas prácticas adicionales

Nombra las redes de forma descriptiva

No llames a tus redes red1, red2 o default. Usa nombres que indiquen su propósito: traefik_public, internal_backend, db_network, cache_network.

Dentro de unos meses, cuando vuelvas a mirar la configuración, agradecerás haber puesto nombres claros.

Usa redes externas para Traefik

Traefik suele estar en un proyecto Docker Compose separado del resto de servicios. Declara traefik_public como red externa en todos los proyectos que la necesiten.

networks:
  traefik_public:
    external: true

Y crea la red antes de desplegar nada:

docker network create traefik_public

No compartas redes de bases de datos

Cada base de datos debería tener su propia red. No pongas PostgreSQL de una aplicación y MySQL de otra en la misma red. Si un atacante compromete una aplicación, no debería poder alcanzar la base de datos de otra.

Usa internal: true cuando puedas

Las redes internas (internal: true) no tienen acceso a internet. Esto limita lo que un atacante puede hacer si compromete un contenedor.

Pero ten cuidado. Algunos contenedores necesitan acceso a internet para funcionar. Por ejemplo, un contenedor que necesita descargar actualizaciones o consultar una API externa. En ese caso, no uses internal: true.

Evalúa cada servicio. Si no necesita internet, pon internal: true. Si lo necesita, usa una red bridge normal pero limita el acceso con firewall.

Documenta el diseño de red

Dibuja un diagrama de tu segmentación. Puede ser un texto simple como el que viste antes, o un diagrama en draw.io. Lo importante es que cualquier persona (o tú mismo dentro de seis meses) entienda qué redes existen y qué contenedores están en cada una.

Mantén la documentación en el mismo repositorio que los archivos Docker Compose. Un simple README.md con el diagrama y la explicación es suficiente.

Revisa periódicamente

La segmentación no es algo que configuras una vez y olvidas. Cuando añades un nuevo servicio, revisa:

  • ¿Qué redes necesita?
  • ¿Qué otros servicios están en esas redes?
  • ¿Debería crear una nueva red para aislar este servicio?

Cada nuevo servicio es una oportunidad para mejorar la segmentación o para romperla.

Resumen

La segmentación de red en Docker es una de las medidas de seguridad más efectivas que puedes implementar. No requiere hardware adicional. No requiere software externo. Solo requiere pensar en cómo se comunican tus servicios.

En este capítulo has visto:

  • El problema de tener todos los contenedores en la misma red Docker.
  • Los tipos de redes Docker: bridge, overlay y macvlan, y cuándo usar cada una.
  • El diseño de tres capas: red pública (traefik_public), red privada (internal_backend) y red de base de datos (db_network).
  • Cómo conectar contenedores a múltiples redes con el mínimo necesario.
  • Por qué las bases de datos y Redis nunca deben exponer puertos al exterior.
  • Cómo integrar Traefik respetando la segmentación: solo ve lo que necesita enrutar.
  • El aislamiento total de bases de datos sin interfaz externa.
  • La regla de no exponer puertos innecesarios: usa expose en lugar de ports.
  • Un ejemplo práctico completo con frontend, API, Redis y PostgreSQL.
  • Pruebas de verificación para confirmar que el aislamiento funciona.

Cada red que creas es una barrera. Cada barrera que pones entre un atacante y tus datos es tiempo que ganas. Y en seguridad, el tiempo es lo único que no puedes recuperar.

Ahora tus servicios Docker no viven en el mismo pasillo abierto. Tienen sus propias habitaciones, con sus propias puertas, y solo tú tienes las llaves para abrir las que realmente necesitas.

En el próximo capítulo vas a llevar la segmentación un paso más allá. Redes Docker está bien para un solo servidor, pero ¿y si tienes varios servidores y quieres que servicios específicos se comuniquen entre máquinas de forma segura? Ahí entran las mallas VPN con Tailscale, Netbird o Headscale. Pero eso es otra historia.


Más información,

Deja una respuesta