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
- Why containers cannot see each other
- The built-in network drivers
- The default bridge versus user-defined networks
- The
docker networkcommands - Port publishing, revisited
- Hands-on:
aurora-netand name resolution - The moment of truth:
/booksreturns the eight books aurora-web: Nginx as a reverse proxy- Connecting and disconnecting networks on the fly
- Networking best practices
- 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: $?"'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,
localhostalways means "myself". To talk to another container you have to use its address: its IP or, much better, its name.
- 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:
NETWORK ID NAME DRIVER SCOPE
6b2f8a1c9e73 bridge bridge local
f19d4c7e2a85 host host local
3a8e1b5f9c24 none null localA 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}"'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 |
- 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:
- The
docker network commands
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
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}}'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
A network with containers connected cannot be deleted (network ... has active endpoints). And the three predefined networks —bridge, host, none— can never be removed.
- 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-dbneeds no-pfor 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 toaurora-db:5432, the internal port. 5433 only exists for the host.
- Hands-on:
aurora-net and name resolution
aurora-net and name resolutionLet's get to work. First, the network:
docker network create --label project=aurora-libros aurora-net
docker network ls --filter name=aurora-netc7f2e9a13b8d46052fa8c1e7b3d9a4f6c2e0b8d5a3f1c9e7b5d3a1f9c7e5b3d1
NETWORK ID NAME DRIVER SCOPE
c7f2e9a13b8d aurora-net bridge localNow 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 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-alpinedocker run -d \
--name aurora-cache \
--network aurora-net \
--label project=aurora-libros --label component=cache \
redis:7-alpineNotice 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;"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.0Verify 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-apiThere 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:
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.
- The moment of truth:
/books returns the eight books
/books returns the eight booksdb: ok. cache: ok. And now, the command you have been waiting for all course:
{
"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:allThe 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:
{
"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.
aurora-web: Nginx as a reverse proxy
aurora-web: Nginx as a reverse proxyThe 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:alpineTwo details of the command:
- The two
-vflags 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:romatters on configuration files. --stop-signal SIGQUITapplies 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-webdoes 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 -cstatic 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.
- 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-dbaurora-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-dbAdminer 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-adminAnd 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-newA 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.
- 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:
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/tcpIn 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
localhostto refer to another container. Inside a container,localhostis 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 withnetwork 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=10sand aproxy_passover a variable, as in this lesson's configuration. - Forgetting the trailing
/inproxy_pass. Without it,/api/booksis forwarded as/api/booksand the API answers 404. With it, it arrives as/books. - Tip:
docker network inspectis the fastest way to answer "who is on this network and with which IP?". - Tip: to diagnose, launch
netshootconnected to the network (--network aurora-net) and trync,digandcurlfrom 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:
client-aresolvesserver-aby name and gets an HTTP 200.client-bdoes not resolve it and cannot reach it, neither by name nor by IP.- Then, without recreating anything, make
client-bable 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:
- Can
mini-apitalk tomini-db? Prove it. - Can you, from the host, talk to
mini-dbwithredis-cliornc? Why? - What does
curl localhost:9999answer and why, ifmini-apihas no web server at all? - Recreate
mini-dbwith-p 127.0.0.1:6380:6379and repeat point 2. Has anything changed formini-api?
Exercise 3: break and fix the reverse proxy
With the Aurora Libros platform working, cause and diagnose two real proxy breakdowns:
- Change the destination in
nginx.conftohttp://aurora-api-nonexistent:3000, reload Nginx withdocker kill -s SIGHUP aurora-weband observe whatcurl localhost:8080/api/booksreturns. Diagnose it withdocker logs aurora-web. - Restore the correct destination but remove the trailing slash from
proxy_pass. Reload and observe the new error. Explain exactly which path is reachingaurora-api(check it withdocker logs aurora-api). - Leave everything correct and prove, by recreating
aurora-apiwithout-p, that the site still works atlocalhost: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 36001. 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-a2. 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: 1There 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-bTwo IPs, one per network, without having stopped or recreated the container. That is only possible with user-defined networks.
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 36001.
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.
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.
The publishing rule exists —docker 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 PINGNow 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.
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-webHTTP 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-apiNow 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'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.0Conclusion
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
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
