You have had the same breakdown for four lessons and you now know exactly what causes it: nslookup aurora-db returns NXDOMAIN because the default bridge network has no internal DNS between containers. The connectivity is there —nc -zv 172.17.0.2 5432 answers succeeded—, but the API does not know its database's name.

This is the lesson that fixes it, and with two commands. But before writing them you are going to genuinely understand Docker's network model: why each container has its own localhost, what each of the built-in drivers does, how the default bridge differs from one you create yourself, and what the difference is —one that gets confused constantly— between publishing a port and two containers talking to each other. Then you will create aurora-net, move the three services inside, and run the curl you have been waiting for all course. And to finish, you will put aurora-web in front with Nginx as a reverse proxy, and open the complete bookshop in your browser.

Contents

  1. Why containers cannot see each other
  2. The built-in network drivers
  3. The default bridge versus user-defined networks
  4. The docker network commands
  5. Port publishing, revisited
  6. Hands-on: aurora-net and name resolution
  7. The moment of truth: /books returns the eight books
  8. aurora-web: Nginx as a reverse proxy
  9. Connecting and disconnecting networks on the fly
  10. Networking best practices

  1. Why containers cannot see each other

A container is not just an isolated process: it has its own network namespace. That means it exclusively owns its list of interfaces, its routing table, its firewall rules, its ports and —this is what confuses everybody— its own localhost.

flowchart TB
    subgraph HOST["Host (your machine)"]
        direction TB
        HL["the host's localhost<br/>127.0.0.1"]
        subgraph C1["aurora-api container"]
            L1["ITS OWN localhost<br/>127.0.0.1"]
            E1["eth0: 172.17.0.4"]
            P1["node listening on :3000"]
        end
        subgraph C2["aurora-db container"]
            L2["ITS OWN localhost<br/>127.0.0.1"]
            E2["eth0: 172.17.0.2"]
            P2["postgres listening on :5432"]
        end
        BR["docker0<br/>bridge 172.17.0.1"]
        E1 --- BR
        E2 --- BR
    end
    L1 -. "NOT the same one" .- L2
    L1 -. "NOT the same one" .- HL

Three different localhosts on the same machine. When server.js was trying to connect to localhost:5432 in module 2, it was asking for PostgreSQL inside its own container, where only node lives. Hence the ECONNREFUSED: somebody answered (the container itself) saying nobody is listening there.

Check it for yourself:

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

Each with its own IP, and on the API's localhost there is no database at all. The rule, in one line:

Inside a container, localhost always means "myself". To talk to another container you have to use its address: its IP or, much better, its name.

  1. The built-in network drivers

Docker implements networks through interchangeable drivers:

Driver What it does Isolation When to use it
bridge A private virtual network on the host; each container gets an internal IP High By default and for almost everything: multi-container applications on one machine
host The container shares the host's network stack: no IP of its own, no NAT None Extreme performance, or services that need to see all the host's ports
none Loopback interface only: no network Total Batch jobs that need no network; maximum security
overlay A network spanning several hosts in a cluster High Docker Swarm. Covered in lesson 06-03
macvlan The container gets a MAC and an IP from your physical network Medium Integrating containers into an existing LAN. Detail in lesson 05-01
ipvlan Similar, sharing the host's MAC Medium Environments with MAC restrictions. Detail in lesson 05-01

The first three come preconfigured and you see them with:

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

A quick look at host and none, which are rarely used but worth recognizing:

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

With --network host, the container has the same IP as your machine: no -p is of any use because the ports are the host's directly, and there is no network isolation at all. With --network none, only lo exists: it cannot even ping anything.

bridge host none
Own IP Yes No, the host's No
Is -p needed Yes No, and it does not work Irrelevant
Network isolation Yes No Total
Performance Very good (there is NAT) The maximum
Port conflicts Only on the host Direct between containers No
Available on Docker Desktop (macOS/Windows) Yes With limitations Yes

  1. The default bridge versus user-defined networks

Here is the heart of the lesson. There are two kinds of bridge network and they behave very differently:

Default bridge (bridge, docker0) User-defined bridge
How it is used Automatic: every container without --network lands here docker network create my-network + --network my-network
Internal DNS between containers NO YES: each container resolves by its name
Isolation All containers together, including those from other projects Only the ones you connect to that network
Connect/disconnect on the fly No Yes, with network connect/disconnect
Network aliases No Yes, with --network-alias
Link environment variables The --link system, obsolete Not needed
Recommendation Avoid it Always use it

Only the first row matters today, but it is decisive: on a network of your own, Docker brings up an internal DNS server at 127.0.0.11 that resolves the names of the containers connected to that network. On the default bridge, that DNS exists but it only forwards queries to the host's servers: it knows no container names. That is, literally, the whole mystery you have been dragging along since lesson 02-06.

Before fixing it, put the starting point on record:

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

  1. The docker network commands

Command What for
docker network ls List networks
docker network create Create a network
docker network inspect See its configuration and its containers
docker network connect Connect a running container to a network
docker network disconnect Disconnect it
docker network rm Delete a network
docker network prune Delete every network with no containers

Creating a network

docker network create aurora-net
c7f2e9a13b8d46052fa8c1e7b3d9a4f6c2e0b8d5a3f1c9e7b5d3a1f9c7e5b3d1

With options, when you need control:

docker network create \
  --driver bridge \
  --subnet 172.28.0.0/16 \
  --gateway 172.28.0.1 \
  --label project=aurora-libros \
  aurora-net-custom
Option What for
--driver The driver; bridge is the default value
--subnet Pin the IP range (useful if it clashes with your VPN)
--gateway That subnet's gateway
--ip-range Restrict the automatic assignment range
--internal A network with no Internet access: total isolation from the outside
--label Labels, as on containers
--attachable Allow standalone containers to connect to an overlay network

--internal deserves attention: an internal network lets containers talk to each other but cuts their access to the outside world. It is an excellent defense for a database that has no business going out to the Internet, and it is picked up again in lesson 05-03.

Inspecting

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

And the most useful query of all: who is connected.

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

For now it prints nothing: the network is empty.

Deleting and cleaning up

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

A network with containers connected cannot be deleted (network ... has active endpoints). And the three predefined networks —bridge, host, none— can never be removed.

  1. Port publishing, revisited

Now that you understand the model, the table from lesson 03-01 makes full sense:

Syntax Effect
-p 3000:3000 Port 3000 on all the host's interfaces → 3000 of the container
-p 127.0.0.1:5432:5432 Only reachable from the host itself
-p 8080-8090:8080-8090 A range of ports
-p 5514:514/udp UDP publishing
-p 3000 A random host port → 3000 of the container
-P Publishes every EXPOSE on random ports
(nothing) The container is not reachable from the host, but it is from its network

And this is the distinction you must be absolutely clear about:

Publishing a port (-p) Container-to-container communication
What it is for Letting the host and the outside world reach the container Letting one container reach another
How it is addressed localhost:HOST_PORT from the host container-name:INTERNAL_PORT
Does it require -p? Yes, that is its definition No, not at all
Which port is used The host's (the one on the left) The container's internal one, always
Requirement None beyond the port being free Being on the same user-defined network

From that come two classic mistakes you can now avoid:

  • Publishing ports "so the containers can see each other". It is unnecessary and on top of that it exposes internal services. aurora-db needs no -p for the API to use it; you only need it if you want to connect with a client from the host.
  • Using the host port in another container's connection string. If you publish -p 5433:5432, another container must still connect to aurora-db:5432, the internal port. 5433 only exists for the host.

  1. Hands-on: aurora-net and name resolution

Let's get to work. First, the network:

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

Now recreate the three services inside it. Since the network is a namespace setting, it is frozen when the container is created (lesson 03-01), so we have to delete and create again:

docker rm -f aurora-db aurora-cache aurora-api
docker run -d \
  --name aurora-db \
  --network aurora-net \
  --label project=aurora-libros --label component=database \
  -e POSTGRES_USER=aurora \
  -e POSTGRES_PASSWORD=aurora_secret \
  -e POSTGRES_DB=aurora_books \
  -p 127.0.0.1:5432:5432 \
  postgres:16-alpine
docker run -d \
  --name aurora-cache \
  --network aurora-net \
  --label project=aurora-libros --label component=cache \
  redis:7-alpine

Notice an important change in aurora-cache: it no longer carries -p. Nobody on the host needs to talk to Redis; only the API does, and for that being on the same network is enough. It is the principle of exposing the bare minimum.

Before starting the API, load the catalog again, because the database container is new:

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

Note this nuisance, because it is the second time you have suffered it: every time you recreate aurora-db you lose the data and have to load it by hand again. That is the gap lesson 03-06 fills.

And now the API:

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

Verify name resolution

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

There it is. The same command that ten minutes ago returned code 2 and not a single line now resolves all three names. You have not changed a line of server.js, or the Dockerfile, or the environment file: you have only put the containers on a network of their own.

Check the real connectivity too, port by port:

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!

All three, by name. And note the detail: aurora-cache answers on 6379 even though you published no port. Publishing is for the host; inside the network, all of a container's ports are available to its neighbors.

And the state of the fleet:

docker ps --filter label=project=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). That parenthesis has been saying unhealthy for four lessons. The HEALTHCHECK you wrote in lesson 02-04 runs its check against /health, /health queries PostgreSQL and Redis, both respond, and for the first time it returns 200. And look at aurora-cache's line: a bare 6379/tcp, with no arrow. Exposed but not published.

  1. The moment of truth: /books returns the eight books

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

db: ok. cache: ok. And now, the command you have been waiting for all course:

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

All eight books. 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 and El tiempo entre costuras, sorted by title, served by an API in a container, read from a PostgreSQL database in another container, across a virtual network you created yourself. Stop for a second on this: the Aurora Libros platform is alive. It is the first time since lesson 01-07, where you ran it by hand in fifteen steps and it failed, that the complete system works end to end.

And there is a second, subtler check that proves the cache is doing its job too:

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

The first call went to PostgreSQL and stored the result in Redis with a TTL of 60 seconds; the second one served it from the cache. The three containers are cooperating: the API talks to the database by its name and to the cache by its own. Check it from the other end too:

curl -s http://localhost:3000/books/2 | jq
{
  "id": 2,
  "title": "Rayuela",
  "author": "Julio Cortázar",
  "isbn": "978-84-376-0494-7",
  "price": "19.90"
}

What has changed exactly

Before (default bridge) Now (aurora-net)
getent hosts aurora-db Nothing, code 2 172.18.0.2 aurora-db
/health 503, db: ko, cache: ko 200, db: ok, cache: ok
Container state Up (unhealthy) Up (healthy)
/books {"error": "Could not fetch the catalog"} All eight books
Changes to the code None

That last row is the lesson to take away: the problem was never in the application or in the image. It was in how the containers connected to each other.

  1. aurora-web: Nginx as a reverse proxy

The fourth service is missing. Your web/index.html from lesson 01-07 calls /api/books, a relative path, not http://localhost:3000/books. That design was deliberate: the browser should talk to a single origin, and the one distributing the traffic inside is Nginx.

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

server {
    listen 80;
    server_name _;

    # Docker resolves container names at 127.0.0.11.
    # With "valid=10s" Nginx re-queries DNS and does not get stuck with a stale IP
    # if aurora-api is recreated and changes address.
    resolver 127.0.0.11 valid=10s;

    # 1) The static site
    location / {
        root  /usr/share/nginx/html;
        index index.html;
    }

    # 2) Everything starting with /api/ is forwarded to 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;
    }
}

Line by line, what matters:

Directive What it does
listen 80 Nginx listens on 80 inside the container
resolver 127.0.0.11 valid=10s Uses Docker's internal DNS and refreshes the resolution every 10 s
set $api_upstream ... + proxy_pass $api_upstream/ By using a variable, Nginx resolves the name on every request instead of only at startup. Without this trick, if you recreate aurora-api and its IP changes, the proxy keeps pointing at the old one and returns 502
The trailing / in proxy_pass ...$api_upstream/ It strips the prefix: /api/books is forwarded as /books, which is the path the API exposes
X-Forwarded-For and friends Standard headers so the API knows who the original client is
proxy_connect_timeout 5s Do not leave requests hanging forever if the API does not respond

Start the container:

docker run -d \
  --name aurora-web \
  --network aurora-net \
  --label project=aurora-libros --label component=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

Two details of the command:

  • The two -v flags mount host files read-only (:ro). It is the minimal syntax from lesson 03-01; lesson 03-06 explains what exactly these mounts are and why :ro matters on configuration files.
  • --stop-signal SIGQUIT applies what you learned in lesson 02-04: Nginx performs a graceful shutdown on SIGQUIT and an abrupt one on SIGTERM. With this option, docker stop aurora-web does not cut off half-served requests.

Check the complete chain:

curl -s -o /dev/null -w "static web: HTTP %{http_code}\n" http://localhost:8080/
curl -s http://localhost:8080/api/books | jq -r '.books[] | .title' | head -4
curl -s http://localhost:8080/api/health | jq -c
static web: 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
{"service":"aurora-api","version":"1.0.0","db":"ok","cache":"ok"}

Open http://localhost:8080 in your browser. The Aurora Libros page loads, calls /api/books, Nginx forwards it to aurora-api, the API queries PostgreSQL or Redis, and the table fills up with the eight titles and their prices in euros. The complete architecture, working:

flowchart LR
    N["Browser<br/>localhost:8080"] --> W
    subgraph NET["aurora-net network (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

Not a single hand-written IP anywhere: aurora-web looks for aurora-api, and aurora-api looks for aurora-db and aurora-cache, all by name.

  1. Connecting and disconnecting networks on the fly

With user-defined networks, and only with them, you can change the topology without recreating anything:

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 now has two interfaces and two IPs, one on each network. It is the usual pattern for giving an administration tool access without opening up the application's network:

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 sees the database, but it does not see aurora-api or aurora-cache, because it is not on aurora-net. Isolation by design.

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

And a note about network aliases, which will be important in module 4:

docker run -d --name aurora-db-new --network aurora-net \
  --network-alias database --network-alias postgres \
  -e POSTGRES_PASSWORD=aurora_secret postgres:16-alpine
docker exec aurora-api getent hosts database
docker rm -f aurora-db-new
172.18.0.6       database

A container can answer to several names on the same network. That is what allows you, for example, to swap the implementation without touching the configuration of whoever consumes it.

  1. Networking best practices

Practice Why
One network per application aurora-net contains only the four Aurora Libros services. Another project, another network. No accidental contact
Never use the default bridge No DNS, no isolation, and every container on the machine thrown in together
Publish only what the outside world needs aurora-web on 8080 and that is it. aurora-cache carries no -p, and aurora-db only on 127.0.0.1
Bind databases to 127.0.0.1 -p 127.0.0.1:5432:5432 lets you use a local client without exposing PostgreSQL to the Wi-Fi
Refer to services by name, never by IP IPs change on every start; names do not
Use the internal port in connection strings If you publish -p 5433:5432, another container still uses aurora-db:5432
Consider --internal for data networks It cuts off Internet access for services that do not need it
Label your networks --label project=aurora-libros lets you clean up without wrecking other people's work

Here is how Aurora Libros's exposed surface looks:

docker ps --filter label=project=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

In a real deployment, even the API's -p 3000:3000 would be unnecessary: if the browser only talks to Nginx, the API does not need to be published. You can try it by recreating it without -p and you will see that http://localhost:8080/api/books still works perfectly, because the proxy reaches it over the internal network.

What this lesson does not cover, and where you will see it: how the bridge works internally (veth pairs, iptables, nat), overlay networks across several hosts, macvlan and network policies are studied in lesson 05-01 and in 06-03.

Common Mistakes and Tips

  • Using localhost to refer to another container. Inside a container, localhost is itself. Use the other container's name.
  • Leaving containers on the default bridge and expecting them to resolve by name. It will never happen. Create a network with docker network create.
  • Writing IPs by hand. It works until the next restart. Docker assigns IPs in start-up order.
  • Using the published host port in a container-to-container connection. With -p 5433:5432, another container must still use 5432.
  • Publishing ports "so they can see each other". It is unnecessary, and it exposes internal services to the whole network.
  • Trying to change network with docker start. The network is frozen at creation. You change it with network connect/disconnect, or by recreating.
  • 502 in Nginx after recreating the API. Nginx cached the old IP. It is solved with resolver 127.0.0.11 valid=10s and a proxy_pass over a variable, as in this lesson's configuration.
  • Forgetting the trailing / in proxy_pass. Without it, /api/books is forwarded as /api/books and the API answers 404. With it, it arrives as /books.
  • Tip: docker network inspect is the fastest way to answer "who is on this network and with which IP?".
  • Tip: to diagnose, launch netshoot connected to the network (--network aurora-net) and try nc, dig and curl from inside it.

Exercises

Exercise 1: demonstrate isolation between networks

Create two networks, net-a and net-b. Bring up a server-a container with nginx:alpine on net-a, and two nicolaka/netshoot clients: client-a on net-a and client-b on net-b. Prove with commands that:

  1. client-a resolves server-a by name and gets an HTTP 200.
  2. client-b does not resolve it and cannot reach it, neither by name nor by IP.
  3. Then, without recreating anything, make client-b able to reach it, and check how many IPs it has after that.

Exercise 2: publishing versus communicating

Bring up a minimal stack on a shop-net network: a mini-db with redis:7-alpine with no -p at all, and a mini-api with nicolaka/netshoot running sleep 3600, publishing -p 9999:80. Answer with commands and explanations:

  1. Can mini-api talk to mini-db? Prove it.
  2. Can you, from the host, talk to mini-db with redis-cli or nc? Why?
  3. What does curl localhost:9999 answer and why, if mini-api has no web server at all?
  4. Recreate mini-db with -p 127.0.0.1:6380:6379 and repeat point 2. Has anything changed for mini-api?

Exercise 3: break and fix the reverse proxy

With the Aurora Libros platform working, cause and diagnose two real proxy breakdowns:

  1. Change the destination in nginx.conf to http://aurora-api-nonexistent:3000, reload Nginx with docker kill -s SIGHUP aurora-web and observe what curl localhost:8080/api/books returns. Diagnose it with docker logs aurora-web.
  2. Restore the correct destination but remove the trailing slash from proxy_pass. Reload and observe the new error. Explain exactly which path is reaching aurora-api (check it with docker logs aurora-api).
  3. Leave everything correct and prove, by recreating aurora-api without -p, that the site still works at localhost:8080.

Solutions

Solution to exercise 1

docker network create net-a
docker network create net-b
docker run -d --name server-a --network net-a nginx:alpine
docker run -d --name client-a --network net-a nicolaka/netshoot sleep 3600
docker run -d --name client-b --network net-b nicolaka/netshoot sleep 3600

1. From client-a:

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

2. From client-b:

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

There are two different failures here and both matter: the name does not resolve (they do not share a network, so net-b's DNS knows nothing about server-a) and the IP does not work either, because they are two separate bridges and traffic between them is not allowed. Compare it with the diagnosis in lesson 03-04: there the name failed but the IP worked, which proved they did share a network. The combination of the two tests is what distinguishes "same network with no DNS" from "different networks".

3. Connecting on the fly:

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

Two IPs, one per network, without having stopped or recreated the container. That is only possible with user-defined networks.

docker rm -f server-a client-a client-b
docker network rm net-a net-b

Solution to exercise 2

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

1.

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

Yes, perfectly, and mini-db publishes no port. Inside a shared network, all of a container's ports are available to its neighbors: -p has nothing to do with it.

2.

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

No. Since mini-db published no port, there is no rule forwarding traffic from the host towards it. The host and Docker's network are two different realms: the host only reaches what has been explicitly published.

3.

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

The publishing rule existsdocker ps shows 0.0.0.0:9999->80/tcp— but inside the container nothing is listening on 80: mini-api only runs sleep. It is the proof that -p checks nothing: it creates the forwarding even if the destination is empty. It is the same mistake made when reversing the order in -p 80:8080.

4.

docker rm -f mini-db
docker run -d --name mini-db --network shop-net -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

Now you can connect from the host on port 6380, and for mini-api absolutely nothing has changed: it still uses mini-db:6379, the internal port. The two paths are independent.

docker rm -f mini-db mini-api
docker network rm shop-net

Solution to exercise 3

1. Non-existent destination:

sed -i 's|http://aurora-api:3000|http://aurora-api-nonexistent: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/books
docker logs --tail 3 aurora-web
HTTP 502
2026/08/04 21:47:12 [error] 31#31: *5 aurora-api-nonexistent could not be resolved (3: Host not found),
client: 172.18.0.1, server: _, request: "GET /api/books HTTP/1.1", host: "localhost:8080"

502 Bad Gateway is the "the proxy could not talk to the destination" error, and the log gives the exact reason: could not be resolved. It is the same NXDOMAIN from lesson 03-04, seen from Nginx. And it is worth noting that the static site is still served without a problem at http://localhost:8080/: only the /api/ route fails.

2. Without the trailing slash:

sed -i 's|http://aurora-api-nonexistent: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/books
docker logs --tail 2 aurora-api
<!DOCTYPE html>...Cannot GET /api/books...
HTTP 404

Now it does reach the API, but with the wrong path. With the trailing slash, proxy_pass http://aurora-api:3000/ replaces the /api/ prefix with /, so /api/books arrives as /books. Without it, Nginx concatenates the full path and the API receives /api/books, a route server.js does not define, hence Express's 404. A single character, and half of the world's misconfigured proxies are explained by it.

3. Restoring and removing the API's publishing:

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 project=aurora-libros --label component=api \
  --env-file ~/aurora-libros/aurora.env \
  auroralibros/aurora-api:1.2.0

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

Exactly what we were after: from the host, port 3000 no longer exists; from the site, the eight books still arrive. The API has stopped being exposed and the service has not suffered, because Nginx reaches it over the internal network. It is precisely the architecture you would want in production: a single front door.

Since in the coming lessons it is handy to be able to call the API directly for testing, publish it again:

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

Conclusion

The mystery is solved and the platform is alive. You know that each container has its own network stack and its own localhost, and that this is why localhost:5432 inside the API was asking for a database that was never there. You know the built-in drivers —bridge for almost everything, host when you want neither isolation nor NAT, none for total isolation— and you know that overlay, macvlan and ipvlan exist for multi-host scenarios and integration with the physical network that you will see in lessons 05-01 and 06-03.

And you have nailed the difference that explained everything: the default bridge has no internal DNS; a user-defined network does. One docker network create aurora-net and one --network aurora-net on each container, and getent hosts aurora-db went from returning code 2 to returning 172.18.0.2 aurora-db. Without touching a line of server.js, or the Dockerfile, or the environment file. You also know how to connect and disconnect networks on the fly to give an administration tool one-off access, and how to give a container network aliases.

You distinguish publishing a port from connecting two containers: -p is for the host and the outside world to get in; inside the network, every port is available by name without publishing anything. That is why aurora-cache no longer carries -p, aurora-db is bound to 127.0.0.1, and you have proved that even the API can live without publishing if Nginx is the only front door.

The result, in one line: curl http://localhost:3000/books returns the eight Aurora Libros books, served by Node from PostgreSQL, cached in Redis with its 60-second TTL, and http://localhost:8080 shows the complete bookshop in the browser through a reverse proxy that forwards /api/ without exposing the API. The healthcheck reads healthy for the first time in the whole course. Four containers, one network, zero hand-written IPs.

One problem remains, and you have already suffered it twice in this very lesson: every time you recreate aurora-db, the eight books disappear and you have to load init.sql again with docker cp. The data lives in an anonymous volume nobody controls, or straight in the writable layer that dies with the container. In the next lesson, Data Persistence with Volumes, you will attack that problem at its root: you will see the three types of mount —managed volumes, bind mounts and tmpfs—, the difference between -v and --mount, where volumes really live and why you should not touch them by hand. You will give PostgreSQL the aurora-data volume, mount init.sql so it runs by itself, and repeat the definitive test: insert a new book, destroy the database container, recreate it, and check that the book is still there.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved