Llevas cuatro lecciones con la misma avería y ya sabes exactamente qué la causa: nslookup aurora-db devuelve NXDOMAIN porque la red bridge por defecto no tiene DNS interno entre contenedores. La conectividad está ahí —nc -zv 172.17.0.2 5432 responde succeeded—, pero la API no sabe cómo se llama su base de datos.

Esta lección es la que lo arregla, y con dos comandos. Pero antes de escribirlos vas a entender de verdad el modelo de red de Docker: por qué cada contenedor tiene su propio localhost, qué hace cada uno de los drivers integrados, en qué se diferencia la bridge por defecto de una red creada por ti, y cuál es la diferencia —que se confunde constantemente— entre publicar un puerto y que dos contenedores se hablen. Después crearás aurora-net, moverás los tres servicios dentro, y ejecutarás el curl que llevas todo el curso esperando. Y para rematar, pondrás delante aurora-web con Nginx como proxy inverso, y abrirás la librería completa en el navegador.

Contenido

  1. Por qué los contenedores no se ven entre sí
  2. Los drivers de red integrados
  3. La bridge por defecto frente a las redes definidas por el usuario
  4. Los comandos de docker network
  5. Publicación de puertos, revisitada
  6. Práctica: aurora-net y la resolución de nombres
  7. El momento de la verdad: /libros devuelve los ocho libros
  8. aurora-web: Nginx como proxy inverso
  9. Conectar y desconectar redes en caliente
  10. Buenas prácticas de red

  1. Por qué los contenedores no se ven entre sí

Un contenedor no es solo un proceso aislado: tiene su propio namespace de red. Eso significa que posee, en exclusiva, su lista de interfaces, su tabla de rutas, sus reglas de cortafuegos, sus puertos y —esto es lo que confunde a todo el mundo— su propio localhost.

flowchart TB
    subgraph HOST["Host (tu máquina)"]
        direction TB
        HL["localhost del host<br/>127.0.0.1"]
        subgraph C1["Contenedor aurora-api"]
            L1["SU localhost<br/>127.0.0.1"]
            E1["eth0: 172.17.0.4"]
            P1["node escuchando en :3000"]
        end
        subgraph C2["Contenedor aurora-db"]
            L2["SU localhost<br/>127.0.0.1"]
            E2["eth0: 172.17.0.2"]
            P2["postgres escuchando en :5432"]
        end
        BR["docker0<br/>bridge 172.17.0.1"]
        E1 --- BR
        E2 --- BR
    end
    L1 -. "NO es el mismo" .- L2
    L1 -. "NO es el mismo" .- HL

Tres localhost distintos en la misma máquina. Cuando server.js intentaba conectarse a localhost:5432 en el módulo 2, estaba preguntando por PostgreSQL dentro de su propio contenedor, donde solo vive node. De ahí el ECONNREFUSED: alguien contestó (el propio contenedor) diciendo que ahí no hay nadie escuchando.

Compruébalo tú mismo:

docker exec aurora-api hostname -i
docker exec aurora-db hostname -i
docker exec aurora-api sh -c 'nc -z localhost 5432; echo "PostgreSQL en mi localhost: $?"'
172.17.0.4
172.17.0.2
PostgreSQL en mi localhost: 1

Cada uno con su IP, y en el localhost de la API no hay ninguna base de datos. La regla, en una línea:

Dentro de un contenedor, localhost siempre significa "yo mismo". Para hablar con otro contenedor hay que usar su dirección: su IP o, mucho mejor, su nombre.

  1. Los drivers de red integrados

Docker implementa las redes mediante drivers intercambiables:

Driver Qué hace Aislamiento Cuándo usarlo
bridge Red virtual privada en el host; cada contenedor recibe una IP interna Alto Por defecto y para casi todo: aplicaciones de varios contenedores en una máquina
host El contenedor comparte la pila de red del host: sin IP propia, sin NAT Ninguno Rendimiento extremo, o servicios que necesitan ver todos los puertos del host
none Solo interfaz de loopback: sin red Total Trabajos por lotes que no necesitan red; máxima seguridad
overlay Red que abarca varios hosts de un clúster Alto Docker Swarm. Se ve en la lección 06-03
macvlan El contenedor recibe una MAC y una IP de tu red física Medio Integrar contenedores en una LAN existente. Detalle en la lección 05-01
ipvlan Similar, compartiendo la MAC del host Medio Entornos con restricciones de MAC. Detalle en la lección 05-01

Las tres primeras vienen preconfiguradas y las ves con:

docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
6b2f8a1c9e73   bridge    bridge    local
f19d4c7e2a85   host      host      local
3a8e1b5f9c24   none      null      local

Un vistazo rápido a host y none, que se usan poco pero conviene reconocer:

docker run --rm --network host alpine:3.20 hostname -i
docker run --rm --network none alpine:3.20 sh -c 'ip -o addr show | awk "{print \$2, \$4}"'
192.168.1.42
lo 127.0.0.1/8

Con --network host, el contenedor tiene la misma IP que tu máquina: no hay -p que valga porque los puertos son directamente los del host, y no hay ningún aislamiento de red. Con --network none, solo existe lo: ni siquiera puede hacer ping a nada.

bridge host none
IP propia No, la del host No
Hace falta -p No, y no funciona Irrelevante
Aislamiento de red No Total
Rendimiento Muy bueno (hay NAT) El máximo
Conflictos de puertos Solo en el host Directos entre contenedores No
Disponible en Docker Desktop (macOS/Windows) Con limitaciones

  1. La bridge por defecto frente a las redes definidas por el usuario

Aquí está el corazón de la lección. Existen dos tipos de red bridge y se comportan de forma muy distinta:

Bridge por defecto (bridge, docker0) Bridge definida por el usuario
Cómo se usa Automática: todo contenedor sin --network cae aquí docker network create mi-red + --network mi-red
DNS interno entre contenedores NO : cada contenedor se resuelve por su nombre
Aislamiento Todos los contenedores juntos, incluidos los de otros proyectos Solo los que conectes a esa red
Conectar/desconectar en caliente No , con network connect/disconnect
Alias de red No Sí, con --network-alias
Variables de entorno de enlace Sistema --link, obsoleto No hace falta
Recomendación Evitarla Usarla siempre

Solo la primera fila importa hoy, pero es determinante: en una red propia, Docker levanta un servidor DNS interno en 127.0.0.11 que resuelve los nombres de los contenedores conectados a esa red. En la bridge por defecto, ese DNS existe pero solo reenvía consultas a los servidores del host: no conoce ningún nombre de contenedor. Ese es, literalmente, todo el misterio que llevas arrastrando desde la lección 02-06.

Antes de arreglarlo, deja constancia del punto de partida:

docker exec aurora-api getent hosts aurora-db; echo "resultado: $?"
resultado: 2

  1. Los comandos de docker network

Comando Para qué
docker network ls Listar redes
docker network create Crear una red
docker network inspect Ver su configuración y sus contenedores
docker network connect Conectar un contenedor en marcha a una red
docker network disconnect Desconectarlo
docker network rm Borrar una red
docker network prune Borrar todas las redes sin contenedores

Crear una red

docker network create aurora-net
c7f2e9a13b8d46052fa8c1e7b3d9a4f6c2e0b8d5a3f1c9e7b5d3a1f9c7e5b3d1

Con opciones, cuando necesites control:

docker network create \
  --driver bridge \
  --subnet 172.28.0.0/16 \
  --gateway 172.28.0.1 \
  --label proyecto=aurora-libros \
  aurora-net-personalizada
Opción Para qué
--driver El driver; bridge es el valor por defecto
--subnet Fijar el rango de IPs (útil si choca con tu VPN)
--gateway La puerta de enlace de esa subred
--ip-range Restringir el rango de asignación automática
--internal Red sin salida a Internet: aislamiento total hacia fuera
--label Etiquetas, como en los contenedores
--attachable Permitir conectar contenedores sueltos a una red overlay

--internal merece atención: una red interna permite que los contenedores se hablen entre sí pero les corta la salida al exterior. Es una defensa excelente para una base de datos que no tiene por qué salir a Internet, y se retoma en la lección 05-03.

Inspeccionar

docker network inspect aurora-net --format '{{.Name}} | driver={{.Driver}} | subred={{range .IPAM.Config}}{{.Subnet}}{{end}}'
aurora-net | driver=bridge | subred=172.18.0.0/16

Y la consulta más útil de todas: quién está conectado.

docker network inspect aurora-net --format '{{range .Containers}}{{.Name}} → {{.IPv4Address}}{{println}}{{end}}'

De momento no imprime nada: la red está vacía.

Borrar y limpiar

docker network rm aurora-net-personalizada
docker network prune -f
aurora-net-personalizada

Una red con contenedores conectados no se puede borrar (network ... has active endpoints). Y las tres redes predefinidas —bridge, host, none— no se pueden eliminar nunca.

  1. Publicación de puertos, revisitada

Ahora que entiendes el modelo, la tabla de la lección 03-01 cobra sentido pleno:

Sintaxis Efecto
-p 3000:3000 Puerto 3000 de todas las interfaces del host → 3000 del contenedor
-p 127.0.0.1:5432:5432 Solo accesible desde el propio host
-p 8080-8090:8080-8090 Un rango de puertos
-p 5514:514/udp Publicación UDP
-p 3000 Puerto aleatorio del host → 3000 del contenedor
-P Publica todos los EXPOSE en puertos aleatorios
(nada) El contenedor no es accesible desde el host, pero sí desde su red

Y esta es la distinción que hay que tener absolutamente clara:

Publicar un puerto (-p) Comunicación entre contenedores
Para qué sirve Que el host y el mundo exterior alcancen el contenedor Que un contenedor alcance a otro
Cómo se dirige localhost:PUERTO_HOST desde el host nombre-contenedor:PUERTO_INTERNO
¿Requiere -p? Sí, es su definición No, en absoluto
Puerto que se usa El del host (el de la izquierda) El interno del contenedor, siempre
Requisito Ninguno salvo que el puerto esté libre Estar en la misma red definida por el usuario

De ahí salen dos errores clásicos que ahora puedes evitar:

  • Publicar puertos "para que los contenedores se vean". No hace falta y encima expone servicios internos. aurora-db no necesita ningún -p para que la API la use; solo lo necesitas tú, si quieres conectarte con un cliente desde el host.
  • Usar el puerto del host en la cadena de conexión de otro contenedor. Si publicas -p 5433:5432, otro contenedor debe seguir conectándose a aurora-db:5432, el puerto interno. El 5433 solo existe para el host.

  1. Práctica: aurora-net y la resolución de nombres

Manos a la obra. Primero, la red:

docker network create --label proyecto=aurora-libros aurora-net
docker network ls --filter name=aurora-net
c7f2e9a13b8d46052fa8c1e7b3d9a4f6c2e0b8d5a3f1c9e7b5d3a1f9c7e5b3d1
NETWORK ID     NAME         DRIVER    SCOPE
c7f2e9a13b8d   aurora-net   bridge    local

Ahora recrea los tres servicios dentro de ella. Como la red es un ajuste de namespace, se fija al crear el contenedor (lección 03-01), así que hay que borrar y volver a crear:

docker rm -f aurora-db aurora-cache aurora-api
docker run -d \
  --name aurora-db \
  --network aurora-net \
  --label proyecto=aurora-libros --label componente=base-de-datos \
  -e POSTGRES_USER=aurora \
  -e POSTGRES_PASSWORD=aurora_secreta \
  -e POSTGRES_DB=aurora_libros \
  -p 127.0.0.1:5432:5432 \
  postgres:16-alpine
docker run -d \
  --name aurora-cache \
  --network aurora-net \
  --label proyecto=aurora-libros --label componente=cache \
  redis:7-alpine

Fíjate en un cambio importante en aurora-cache: ya no lleva -p. Nadie desde el host necesita hablar con Redis; solo la API, y para eso basta con estar en la misma red. Es el principio de exponer lo mínimo imprescindible.

Antes de arrancar la API, vuelve a cargar el catálogo, porque el contenedor de la base de datos es nuevo:

sleep 5
docker cp ~/aurora-libros/db/init.sql aurora-db:/tmp/init.sql
docker exec aurora-db psql -U aurora -d aurora_libros -f /tmp/init.sql
docker exec aurora-db psql -U aurora -d aurora_libros -t -c "SELECT COUNT(*) FROM libros;"
Successfully copied 3.07kB to aurora-db:/tmp/init.sql
CREATE TABLE
CREATE INDEX
INSERT 0 8
     8

Anota esta molestia, porque es la segunda vez que la sufres: cada vez que recreas aurora-db pierdes los datos y tienes que volver a cargarlos a mano. Esa es la carencia que resuelve la lección 03-06.

Y ahora la API:

docker run -d \
  --name aurora-api \
  --network aurora-net \
  --label proyecto=aurora-libros --label componente=api \
  --env-file ~/aurora-libros/aurora.env \
  -p 3000:3000 \
  auroralibros/aurora-api:1.2.0

Verifica la resolución de nombres

docker exec aurora-api getent hosts aurora-db
docker exec aurora-api getent hosts aurora-cache
docker exec aurora-api getent hosts aurora-api
172.18.0.2       aurora-db
172.18.0.3       aurora-cache
172.18.0.4       aurora-api

Ahí está. El mismo comando que hace diez minutos devolvía código 2 y ninguna línea, ahora resuelve los tres nombres. No has cambiado ni una línea de server.js, ni el Dockerfile, ni el fichero de entorno: solo has puesto los contenedores en una red propia.

Comprueba también la conectividad real, puerto a puerto:

docker run --rm --network aurora-net nicolaka/netshoot \
  sh -c 'nc -zv aurora-db 5432; nc -zv aurora-cache 6379; nc -zv aurora-api 3000'
Connection to aurora-db (172.18.0.2) 5432 port [tcp/postgresql] succeeded!
Connection to aurora-cache (172.18.0.3) 6379 port [tcp/redis] succeeded!
Connection to aurora-api (172.18.0.4) 3000 port [tcp/*] succeeded!

Los tres, por nombre. Y observa el detalle: aurora-cache responde en el 6379 aunque no publicaste ningún puerto. La publicación es para el host; dentro de la red, todos los puertos del contenedor están disponibles para sus vecinos.

Y el estado de la flota:

docker ps --filter label=proyecto=aurora-libros --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
NAMES          STATUS                   PORTS
aurora-api     Up 30 seconds (healthy)  0.0.0.0:3000->3000/tcp
aurora-cache   Up 1 minute              6379/tcp
aurora-db      Up 2 minutes             127.0.0.1:5432->5432/tcp

(healthy). Ese paréntesis lleva cuatro lecciones diciendo unhealthy. El HEALTHCHECK que escribiste en la lección 02-04 hace su comprobación contra /salud, /salud consulta PostgreSQL y Redis, ambos responden, y por primera vez devuelve 200. Y fíjate en la línea de aurora-cache: 6379/tcp a secas, sin flecha. Expuesto pero no publicado.

  1. El momento de la verdad: /libros devuelve los ocho libros

curl -s http://localhost:3000/salud | jq
{
  "servicio": "aurora-api",
  "version": "1.0.0",
  "db": "ok",
  "cache": "ok"
}

db: ok. cache: ok. Y ahora, el comando que llevas todo el curso esperando:

curl -s http://localhost:3000/libros | jq
{
  "origen": "db",
  "libros": [
    { "id": 3, "titulo": "Cien años de soledad", "autor": "Gabriel García Márquez", "isbn": "978-84-397-2071-7", "precio": "17.95" },
    { "id": 1, "titulo": "El jardín de senderos que se bifurcan", "autor": "Jorge Luis Borges", "isbn": "978-84-206-3312-1", "precio": "14.50" },
    { "id": 8, "titulo": "El tiempo entre costuras", "autor": "María Dueñas", "isbn": "978-84-8365-351-1", "precio": "20.15" },
    { "id": 6, "titulo": "La casa de los espíritus", "autor": "Isabel Allende", "isbn": "978-84-9838-618-3", "precio": "18.40" },
    { "id": 4, "titulo": "La sombra del viento", "autor": "Carlos Ruiz Zafón", "isbn": "978-84-08-04364-5", "precio": "21.00" },
    { "id": 7, "titulo": "Los detectives salvajes", "autor": "Roberto Bolaño", "isbn": "978-84-339-6835-7", "precio": "23.60" },
    { "id": 5, "titulo": "Nada", "autor": "Carmen Laforet", "isbn": "978-84-233-4361-2", "precio": "12.75" },
    { "id": 2, "titulo": "Rayuela", "autor": "Julio Cortázar", "isbn": "978-84-376-0494-7", "precio": "19.90" }
  ]
}

Los ocho libros. El jardín de senderos que se bifurcan, Rayuela, Cien años de soledad, La sombra del viento, Nada, La casa de los espíritus, Los detectives salvajes y El tiempo entre costuras, ordenados por título, servidos por una API en un contenedor, leídos de una base de datos PostgreSQL en otro contenedor, a través de una red virtual que has creado tú. Párate un segundo en esto: la plataforma de Aurora Libros está viva. Es la primera vez desde la lección 01-07, donde la ejecutabas a mano en quince pasos y fallaba, que el sistema completo funciona de extremo a extremo.

Y hay una segunda comprobación, más sutil, que demuestra que la caché también está haciendo su trabajo:

curl -s http://localhost:3000/libros | jq -r '.origen'
curl -s http://localhost:3000/libros | jq -r '.origen'
docker exec aurora-cache redis-cli KEYS '*'
docker exec aurora-cache redis-cli TTL libros:todos
db
cache
1) "libros:todos"
(integer) 47

La primera llamada fue a PostgreSQL y guardó el resultado en Redis con un TTL de 60 segundos; la segunda ya lo sirvió desde la caché. Los tres contenedores están cooperando: la API habla con la base de datos por su nombre y con la caché por el suyo. Compruébalo también por el otro extremo:

curl -s http://localhost:3000/libros/2 | jq
{
  "id": 2,
  "titulo": "Rayuela",
  "autor": "Julio Cortázar",
  "isbn": "978-84-376-0494-7",
  "precio": "19.90"
}

Qué ha cambiado exactamente

Antes (bridge por defecto) Ahora (aurora-net)
getent hosts aurora-db Nada, código 2 172.18.0.2 aurora-db
/salud 503, db: ko, cache: ko 200, db: ok, cache: ok
Estado del contenedor Up (unhealthy) Up (healthy)
/libros {"error": "No se pudo obtener el catálogo"} Los ocho libros
Cambios en el código Ninguno

Esa última fila es la lección que hay que llevarse: el problema nunca estuvo en la aplicación ni en la imagen. Estaba en cómo se conectaban los contenedores entre sí.

  1. aurora-web: Nginx como proxy inverso

Falta el cuarto servicio. Tu web/index.html de la lección 01-07 llama a /api/libros, una ruta relativa, no a http://localhost:3000/libros. Ese diseño era deliberado: el navegador debe hablar con un único origen, y quien reparte por dentro es Nginx.

Crea ~/aurora-libros/web/nginx.conf:

server {
    listen 80;
    server_name _;

    # Docker resuelve los nombres de contenedor en 127.0.0.11.
    # Con "valid=10s" Nginx reconsulta el DNS y no se queda con una IP vieja
    # si aurora-api se recrea y cambia de dirección.
    resolver 127.0.0.11 valid=10s;

    # 1) La web estática
    location / {
        root  /usr/share/nginx/html;
        index index.html;
    }

    # 2) Todo lo que empiece por /api/ se reenvía a aurora-api
    location /api/ {
        set $api_upstream http://aurora-api:3000;
        proxy_pass $api_upstream/;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 5s;
        proxy_read_timeout   30s;
    }
}

Línea a línea, lo que importa:

Directiva Qué hace
listen 80 Nginx escucha en el 80 dentro del contenedor
resolver 127.0.0.11 valid=10s Usa el DNS interno de Docker y refresca la resolución cada 10 s
set $api_upstream ... + proxy_pass $api_upstream/ Al usar una variable, Nginx resuelve el nombre en cada petición en lugar de solo al arrancar. Sin este truco, si recreas aurora-api y cambia su IP, el proxy sigue apuntando a la vieja y devuelve 502
La / final de proxy_pass ...$api_upstream/ Recorta el prefijo: /api/libros se reenvía como /libros, que es la ruta que expone la API
X-Forwarded-For y compañía Cabeceras estándar para que la API sepa quién es el cliente original
proxy_connect_timeout 5s No dejar peticiones colgadas eternamente si la API no responde

Arranca el contenedor:

docker run -d \
  --name aurora-web \
  --network aurora-net \
  --label proyecto=aurora-libros --label componente=web \
  -p 8080:80 \
  -v ~/aurora-libros/web/index.html:/usr/share/nginx/html/index.html:ro \
  -v ~/aurora-libros/web/nginx.conf:/etc/nginx/conf.d/default.conf:ro \
  --stop-signal SIGQUIT \
  nginx:alpine

Dos detalles del comando:

  • Los dos -v montan ficheros del host en solo lectura (:ro). Es la sintaxis mínima de la lección 03-01; la lección 03-06 explica qué son exactamente estos montajes y por qué :ro es importante en ficheros de configuración.
  • --stop-signal SIGQUIT aplica lo que aprendiste en la lección 02-04: Nginx hace un apagado ordenado con SIGQUIT y uno abrupto con SIGTERM. Con esta opción, docker stop aurora-web no corta peticiones a medio servir.

Comprueba la cadena completa:

curl -s -o /dev/null -w "web estática: HTTP %{http_code}\n" http://localhost:8080/
curl -s http://localhost:8080/api/libros | jq -r '.libros[] | .titulo' | head -4
curl -s http://localhost:8080/api/salud | jq -c
web estática: HTTP 200
Cien años de soledad
El jardín de senderos que se bifurcan
El tiempo entre costuras
La casa de los espíritus
{"servicio":"aurora-api","version":"1.0.0","db":"ok","cache":"ok"}

Abre http://localhost:8080 en el navegador. La página de Aurora Libros carga, llama a /api/libros, Nginx la reenvía a aurora-api, la API consulta PostgreSQL o Redis, y la tabla se rellena con los ocho títulos y sus precios en euros. La arquitectura completa, funcionando:

flowchart LR
    N["Navegador<br/>localhost:8080"] --> W
    subgraph NET["Red aurora-net (172.18.0.0/16)"]
        W["aurora-web<br/>nginx:alpine<br/>:80"]
        A["aurora-api<br/>node 22<br/>:3000"]
        D["aurora-db<br/>postgres 16<br/>:5432"]
        C["aurora-cache<br/>redis 7<br/>:6379"]
        W -- "/api/ → http://aurora-api:3000/" --> A
        A -- "aurora-db:5432" --> D
        A -- "aurora-cache:6379" --> C
    end

Ni una sola IP escrita a mano en ninguna parte: aurora-web busca aurora-api, y aurora-api busca aurora-db y aurora-cache, todos por nombre.

  1. Conectar y desconectar redes en caliente

Con redes definidas por el usuario, y solo con ellas, puedes cambiar la topología sin recrear nada:

docker network create aurora-admin
docker network connect aurora-admin aurora-db
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}' aurora-db
aurora-admin=172.19.0.2 aurora-net=172.18.0.2

aurora-db tiene ahora dos interfaces y dos IPs, una en cada red. Es el patrón habitual para dar acceso a una herramienta de administración sin abrir la red de la aplicación:

docker run -d --name aurora-adminer --network aurora-admin -p 8081:8080 adminer:latest
docker exec aurora-adminer getent hosts aurora-db
172.19.0.2       aurora-db

Adminer ve la base de datos, pero no ve aurora-api ni aurora-cache, porque no está en aurora-net. Aislamiento por diseño.

docker network disconnect aurora-admin aurora-db
docker rm -f aurora-adminer
docker network rm aurora-admin

Y un apunte sobre alias de red, que serán importantes en el módulo 4:

docker run -d --name aurora-db-nuevo --network aurora-net \
  --network-alias base-de-datos --network-alias postgres \
  -e POSTGRES_PASSWORD=aurora_secreta postgres:16-alpine
docker exec aurora-api getent hosts base-de-datos
docker rm -f aurora-db-nuevo
172.18.0.6       base-de-datos

Un contenedor puede responder a varios nombres en la misma red. Es lo que permite, por ejemplo, cambiar de implementación sin tocar la configuración de quien la consume.

  1. Buenas prácticas de red

Práctica Por qué
Una red por aplicación aurora-net contiene solo los cuatro servicios de Aurora Libros. Otro proyecto, otra red. Sin contactos accidentales
Nunca uses la bridge por defecto Sin DNS, sin aislamiento y con todos los contenedores de la máquina juntos
Publica solo lo que necesite el exterior aurora-web en el 8080 y ya está. aurora-cache no lleva -p, y aurora-db solo en 127.0.0.1
Ata las bases de datos a 127.0.0.1 -p 127.0.0.1:5432:5432 te deja usar un cliente local sin exponer PostgreSQL a la wifi
Refiérete a los servicios por nombre, jamás por IP Las IPs cambian en cada arranque; los nombres no
Usa el puerto interno en las cadenas de conexión Si publicas -p 5433:5432, otro contenedor sigue usando aurora-db:5432
Considera --internal para las redes de datos Corta la salida a Internet de servicios que no la necesitan
Etiqueta las redes --label proyecto=aurora-libros permite limpiar sin destrozar lo ajeno

Cómo queda la superficie expuesta de Aurora Libros:

docker ps --filter label=proyecto=aurora-libros --format "table {{.Names}}\t{{.Ports}}"
NAMES          PORTS
aurora-web     0.0.0.0:8080->80/tcp
aurora-api     0.0.0.0:3000->3000/tcp
aurora-cache   6379/tcp
aurora-db      127.0.0.1:5432->5432/tcp

En un despliegue real, incluso el -p 3000:3000 de la API sobraría: si el navegador solo habla con Nginx, la API no necesita estar publicada. Puedes probarlo recreándola sin -p y verás que http://localhost:8080/api/libros sigue funcionando perfectamente, porque el proxy la alcanza por la red interna.

Lo que esta lección no cubre, y dónde se ve: cómo funciona el bridge por dentro (parejas veth, iptables, nat), las redes overlay entre varios hosts, macvlan y las políticas de red se estudian en la lección 05-01 y en la 06-03.

Errores Comunes y Consejos

  • Usar localhost para referirse a otro contenedor. Dentro de un contenedor, localhost es él mismo. Usa el nombre del otro contenedor.
  • Dejar los contenedores en la bridge por defecto y esperar que se resuelvan por nombre. No ocurrirá jamás. Crea una red con docker network create.
  • Escribir IPs a mano. Funciona hasta el siguiente reinicio. Docker asigna las IPs por orden de arranque.
  • Usar el puerto publicado del host en la conexión entre contenedores. Con -p 5433:5432, otro contenedor debe seguir usando el 5432.
  • Publicar puertos "para que se vean entre ellos". No hace falta, y expone servicios internos a toda la red.
  • Intentar cambiar de red con docker start. La red se fija al crear. Se cambia con network connect/disconnect, o recreando.
  • 502 en Nginx tras recrear la API. Nginx cacheó la IP antigua. Se resuelve con resolver 127.0.0.11 valid=10s y proxy_pass sobre una variable, como en la configuración de esta lección.
  • Olvidar la / final de proxy_pass. Sin ella, /api/libros se reenvía como /api/libros y la API responde 404. Con ella, llega como /libros.
  • Consejo: docker network inspect es la forma más rápida de responder a "¿quién está en esta red y con qué IP?".
  • Consejo: para diagnosticar, lanza netshoot conectado a la red (--network aurora-net) y prueba nc, dig y curl desde dentro de ella.

Ejercicios

Ejercicio 1: demuestra el aislamiento entre redes

Crea dos redes, red-a y red-b. Levanta un contenedor servidor-a con nginx:alpine en red-a, y dos clientes de nicolaka/netshoot: cliente-a en red-a y cliente-b en red-b. Demuestra con comandos que:

  1. cliente-a resuelve servidor-a por nombre y obtiene un HTTP 200.
  2. cliente-b no lo resuelve ni lo alcanza, ni por nombre ni por IP.
  3. Después, sin recrear nada, haz que cliente-b pueda alcanzarlo, y verifica cuántas IPs tiene entonces.

Ejercicio 2: publicar frente a comunicar

Levanta una pila mínima en una red red-tienda: un mini-db con redis:7-alpine sin ningún -p, y un mini-api con nicolaka/netshoot que ejecute sleep 3600, publicando -p 9999:80. Responde con comandos y explicaciones:

  1. ¿Puede mini-api hablar con mini-db? Demuéstralo.
  2. ¿Puedes tú, desde el host, hablar con mini-db con redis-cli o nc? ¿Por qué?
  3. ¿Qué responde curl localhost:9999 y por qué, si mini-api no tiene ningún servidor web?
  4. Recrea mini-db con -p 127.0.0.1:6380:6379 y repite el punto 2. ¿Ha cambiado algo para mini-api?

Ejercicio 3: rompe y arregla el proxy inverso

Con la plataforma de Aurora Libros funcionando, provoca y diagnostica dos averías reales del proxy:

  1. Cambia en nginx.conf el destino a http://aurora-api-inexistente:3000, recarga Nginx con docker kill -s SIGHUP aurora-web y observa qué devuelve curl localhost:8080/api/libros. Diagnostica con docker logs aurora-web.
  2. Restaura el destino correcto pero quita la barra final de proxy_pass. Recarga y observa el nuevo error. Explica exactamente qué ruta le está llegando a aurora-api (compruébalo con docker logs aurora-api).
  3. Deja todo correcto y demuestra, recreando aurora-api sin -p, que la web sigue funcionando en localhost:8080.

Soluciones

Solución al ejercicio 1

docker network create red-a
docker network create red-b
docker run -d --name servidor-a --network red-a nginx:alpine
docker run -d --name cliente-a --network red-a nicolaka/netshoot sleep 3600
docker run -d --name cliente-b --network red-b nicolaka/netshoot sleep 3600

1. Desde cliente-a:

docker exec cliente-a getent hosts servidor-a
docker exec cliente-a curl -s -o /dev/null -w "HTTP %{http_code}\n" http://servidor-a
172.20.0.2       servidor-a
HTTP 200

2. Desde cliente-b:

docker exec cliente-b nslookup servidor-a 2>&1 | tail -2
docker exec cliente-b sh -c 'nc -zv -w 3 172.20.0.2 80; echo "código: $?"'
** server can't find servidor-a: NXDOMAIN
nc: connect to 172.20.0.2 port 80 (tcp) failed: Connection timed out
código: 1

Aquí hay dos fallos distintos y ambos importan: el nombre no se resuelve (no comparten red, así que el DNS de red-b no conoce a servidor-a) y tampoco funciona la IP, porque son dos bridges separadas y el tráfico entre ellas no está permitido. Compáralo con el diagnóstico de la lección 03-04: allí el nombre fallaba pero la IP funcionaba, lo que demostraba que sí compartían red. La combinación de las dos pruebas es lo que distingue "misma red sin DNS" de "redes distintas".

3. Conectar en caliente:

docker network connect red-a cliente-b
docker exec cliente-b curl -s -o /dev/null -w "HTTP %{http_code}\n" http://servidor-a
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}' cliente-b
HTTP 200
red-a=172.20.0.4 red-b=172.21.0.3

Dos IPs, una por red, sin haber parado ni recreado el contenedor. Eso solo es posible con redes definidas por el usuario.

docker rm -f servidor-a cliente-a cliente-b
docker network rm red-a red-b

Solución al ejercicio 2

docker network create red-tienda
docker run -d --name mini-db --network red-tienda redis:7-alpine
docker run -d --name mini-api --network red-tienda -p 9999:80 nicolaka/netshoot sleep 3600

1.

docker exec mini-api redis-cli -h mini-db PING
PONG

, perfectamente, y mini-db no publica ningún puerto. Dentro de una red compartida, todos los puertos del contenedor están disponibles para sus vecinos: -p no tiene nada que ver con esto.

2.

nc -zv -w 3 localhost 6379; echo "código: $?"
nc: connect to localhost port 6379 (tcp) failed: Connection refused
código: 1

No. Como mini-db no publicó ningún puerto, no existe ninguna regla que reenvíe tráfico del host hacia él. El host y la red de Docker son dos ámbitos distintos: el host solo alcanza lo que se ha publicado explícitamente.

3.

curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:9999
HTTP 000

La publicación existedocker ps muestra 0.0.0.0:9999->80/tcp—, pero dentro del contenedor no hay nada escuchando en el 80: mini-api solo ejecuta sleep. Es la demostración de que -p no comprueba nada: crea el reenvío aunque el destino esté vacío. Es el mismo error que se comete al invertir el orden en -p 80:8080.

4.

docker rm -f mini-db
docker run -d --name mini-db --network red-tienda -p 127.0.0.1:6380:6379 redis:7-alpine
redis-cli -p 6380 PING 2>/dev/null || docker run --rm --network host redis:7-alpine redis-cli -p 6380 PING
docker exec mini-api redis-cli -h mini-db PING
PONG
PONG

Ahora puedes conectarte desde el host por el puerto 6380, y para mini-api no ha cambiado absolutamente nada: sigue usando mini-db:6379, el puerto interno. Los dos caminos son independientes.

docker rm -f mini-db mini-api
docker network rm red-tienda

Solución al ejercicio 3

1. Destino inexistente:

sed -i 's|http://aurora-api:3000|http://aurora-api-inexistente:3000|' ~/aurora-libros/web/nginx.conf
docker kill -s SIGHUP aurora-web
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8080/api/libros
docker logs --tail 3 aurora-web
HTTP 502
2026/08/04 21:47:12 [error] 31#31: *5 aurora-api-inexistente could not be resolved (3: Host not found),
client: 172.18.0.1, server: _, request: "GET /api/libros HTTP/1.1", host: "localhost:8080"

502 Bad Gateway es el error de "el proxy no pudo hablar con el destino", y el log da el motivo exacto: could not be resolved. Es el mismo NXDOMAIN de la lección 03-04, visto desde Nginx. Y merece la pena notar que la web estática sigue sirviéndose sin problema en http://localhost:8080/: solo falla la ruta /api/.

2. Sin la barra final:

sed -i 's|http://aurora-api-inexistente:3000|http://aurora-api:3000|' ~/aurora-libros/web/nginx.conf
sed -i 's|proxy_pass $api_upstream/;|proxy_pass $api_upstream;|' ~/aurora-libros/web/nginx.conf
docker kill -s SIGHUP aurora-web
curl -s -w "\nHTTP %{http_code}\n" http://localhost:8080/api/libros
docker logs --tail 2 aurora-api
<!DOCTYPE html>...Cannot GET /api/libros...
HTTP 404

Ahora sí llega a la API, pero con la ruta equivocada. Con la barra final, proxy_pass http://aurora-api:3000/ sustituye el prefijo /api/ por /, así que /api/libros llega como /libros. Sin ella, Nginx concatena la ruta completa y la API recibe /api/libros, una ruta que server.js no define, de ahí el 404 de Express. Un solo carácter, y la mitad de los proxies mal configurados del mundo se explican por él.

3. Restaurar y quitar la publicación de la API:

sed -i 's|proxy_pass $api_upstream;|proxy_pass $api_upstream/;|' ~/aurora-libros/web/nginx.conf
docker kill -s SIGHUP aurora-web

docker rm -f aurora-api
docker run -d --name aurora-api --network aurora-net \
  --label proyecto=aurora-libros --label componente=api \
  --env-file ~/aurora-libros/aurora.env \
  auroralibros/aurora-api:1.2.0

sleep 5
curl -s -o /dev/null -w "API directa (3000): HTTP %{http_code}\n" http://localhost:3000/libros
curl -s http://localhost:8080/api/libros | jq -r '.libros | length'
API directa (3000): HTTP 000
8

Justo lo que se buscaba: desde el host, el puerto 3000 ya no existe; desde la web, los ocho libros siguen llegando. La API ha dejado de estar expuesta y el servicio no se ha resentido, porque Nginx la alcanza por la red interna. Es exactamente la arquitectura que querrías en producción: una sola puerta de entrada.

Como en las siguientes lecciones conviene poder llamar a la API directamente para hacer pruebas, vuelve a dejarla publicada:

docker rm -f aurora-api
docker run -d --name aurora-api --network aurora-net \
  --label proyecto=aurora-libros --label componente=api \
  --env-file ~/aurora-libros/aurora.env -p 3000:3000 \
  auroralibros/aurora-api:1.2.0

Conclusión

El misterio está resuelto y la plataforma está viva. Sabes que cada contenedor tiene su propia pila de red y su propio localhost, y que por eso localhost:5432 dentro de la API preguntaba por una base de datos que nunca estuvo ahí. Conoces los drivers integrados —bridge para casi todo, host cuando no quieres aislamiento ni NAT, none para aislamiento total— y sabes que overlay, macvlan e ipvlan existen para escenarios de varios hosts y de integración con la red física que verás en las lecciones 05-01 y 06-03.

Y tienes clavada la diferencia que lo explicaba todo: la bridge por defecto no tiene DNS interno; una red definida por el usuario sí. Un docker network create aurora-net y un --network aurora-net en cada contenedor, y getent hosts aurora-db pasó de devolver código 2 a devolver 172.18.0.2 aurora-db. Sin tocar una línea de server.js, ni el Dockerfile, ni el fichero de entorno. También sabes conectar y desconectar redes en caliente para dar acceso puntual a una herramienta de administración, y poner alias de red a un contenedor.

Distingues publicar un puerto de comunicar dos contenedores: -p es para que el host y el exterior entren; dentro de la red, todos los puertos están disponibles por nombre sin publicar nada. Por eso aurora-cache ya no lleva -p, aurora-db está atada a 127.0.0.1, y has demostrado que hasta la API puede vivir sin publicación si Nginx es la única puerta de entrada.

El resultado, en una línea: curl http://localhost:3000/libros devuelve los ocho libros de Aurora Libros, servidos por Node desde PostgreSQL, cacheados en Redis con su TTL de 60 segundos, y http://localhost:8080 muestra la librería completa en el navegador a través de un proxy inverso que reenvía /api/ sin exponer la API. El healthcheck marca healthy por primera vez en todo el curso. Cuatro contenedores, una red, cero IPs escritas a mano.

Queda un problema, y ya lo has sufrido dos veces en esta misma lección: cada vez que recreas aurora-db, los ocho libros desaparecen y tienes que volver a cargar init.sql con docker cp. Los datos viven en un volumen anónimo que nadie controla, o directamente en la capa de escritura que muere con el contenedor. En la siguiente lección, Persistencia de Datos con Volúmenes, atacarás ese problema de raíz: verás los tres tipos de montaje —volúmenes gestionados, bind mounts y tmpfs—, la diferencia entre -v y --mount, dónde viven realmente los volúmenes y por qué no debes tocarlos a mano. Le darás a PostgreSQL el volumen aurora-datos, montarás init.sql para que se ejecute solo, y repetirás la prueba definitiva: insertar un libro nuevo, destruir el contenedor de la base de datos, recrearlo, y comprobar que el libro sigue ahí.

Docker: De Principiante a Avanzado

Módulo 1: Introducción a Docker

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados