So far you have seen containers that work and containers that fail obviously. In real work, the most frequent situation is a third one: a container that does not work and does not say why. It is Up 3 hours but it does not respond. It exits with code 1 and disappears before you can look at it. It gets marked unhealthy with no further explanation. And there you are, with twenty tabs open, trying things at random.

This lesson gives you a method and the five tools that support it: docker logs, docker exec, docker inspect, docker stats and docker events. You are going to learn to read them thoroughly, to get into minimal images that do not have so much as ping, and to extract exactly the piece of data you need from inspect's JSON. And you are going to apply it to three real Aurora Libros breakdowns, among them the one that has been waiting for you for three lessons: why aurora-api cannot find aurora-db even though both are running on the same machine. By the end of this lesson you will know the exact answer.

Contents

  1. A method before a command
  2. docker logs in depth
  3. The golden rule: stdout and stderr
  4. docker exec: getting into the container
  5. Debugging minimal images with no tools
  6. docker inspect: the container's complete JSON
  7. docker top: the processes inside
  8. docker stats: real-time consumption
  9. docker events: the daemon's stream
  10. Case A: the container exits with code 1
  11. Case B: ECONNREFUSED on /books
  12. Case C: the healthcheck stuck on unhealthy

  1. A method before a command

The difference between someone who debugs fast and someone who flails around is not the commands, but the order in which the questions get asked:

flowchart TD
    A["Something is not working"] --> B{"Is it running?<br/>docker ps -a"}
    B -- "Not listed" --> B1["It was never created:<br/>check the docker run (code 125)"]
    B -- "Created" --> B2["The executable does not exist<br/>or could not be run (126/127)"]
    B -- "Exited" --> C{"Which exit<br/>code?"}
    B -- "Restarting" --> R["Restart loop:<br/>logs + policy (03-07)"]
    B -- "Up" --> D{"Is it healthy?"}
    C -- "1-124" --> L["docker logs:<br/>YOUR application failed"]
    C -- "137 / 139 / 143" --> S["docker inspect:<br/>signal, OOMKilled, Error"]
    D -- "unhealthy" --> H["docker inspect<br/>.State.Health.Log"]
    D -- "healthy / no check" --> E{"Do the logs<br/>say anything?"}
    E -- "Yes" --> L
    E -- "No" --> F{"How is it<br/>configured?"}
    F --> G["docker inspect:<br/>environment, network, mounts, ports"]
    G --> I["docker exec:<br/>look from inside"]
    I --> J["Ephemeral container with netshoot<br/>if tools are missing"]

The four questions, in this order and without skipping any:

# Question Tool What it rules out
1 Is it running? docker ps -a Distinguishes "it did not start" from "it started and failed"
2 What do the logs say? docker logs Solves most cases in 10 seconds
3 How is it configured? docker inspect Variables, network, mounts, ports: what you thought you had set
4 What is happening inside? docker exec, top, stats What can only be seen from inside

The most common mistake is to start at 4 —getting into the container to look around— when the answer was in 2. And the second most common mistake is never doing 3, spending hours convinced you passed an environment variable that in fact had a typo.

  1. docker logs in depth

docker logs aurora-db
PostgreSQL init process complete; ready for start up.
2026-08-04 19:28:43.117 UTC [1] LOG:  starting PostgreSQL 16.4 on x86_64-pc-linux-musl
2026-08-04 19:28:43.121 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5432
2026-08-04 19:28:43.198 UTC [1] LOG:  database system is ready to accept connections

The options that actually get used:

Option What it does Example
-f, --follow Follows the output live docker logs -f aurora-api
--tail N Only the last N lines docker logs --tail 50 aurora-db
-t, --timestamps Adds a timestamp to each line docker logs -t aurora-db
--since From a given moment --since 10m, --since 2026-08-04T19:30:00
--until Up to a given moment --until 5m
--details Shows extra metadata Rarely used

Combinations that solve real problems:

# The last 20 lines and then follow live: the most used of the day to day
docker logs -f --tail 20 aurora-db

# What happened in the last 5 minutes, with exact times
docker logs -t --since 5m aurora-db

# Narrowing to a specific window around an incident
docker logs -t --since 2026-08-04T19:28:00 --until 2026-08-04T19:29:00 aurora-db

# Searching for errors across the whole history
docker logs aurora-db 2>&1 | grep -i "error\|fatal"

Three important details:

  • docker logs works with the container stopped. The logs live on the host and survive docker stop. They only disappear with docker rm. That is why --rm is your enemy while debugging.
  • --tail without -f is the fastest way to see the end of a 100,000-line log without dumping the whole thing to your terminal.
  • With -f, Ctrl+C only interrupts the viewer: it does not touch the container. Unlike docker attach, here Ctrl+C is completely safe.

  1. The golden rule: stdout and stderr

docker logs shows exactly two things: the standard output and the standard error of PID 1. Not one thing more.

docker run --rm --name demo-streams alpine:3.20 \
  sh -c 'echo "this goes to stdout"; echo "this goes to stderr" >&2; echo "this goes to a file" > /tmp/hidden.log'
this goes to stdout
this goes to stderr

The third line does not appear anywhere, because it was written to a file inside the container. You can separate the two streams with the shell's standard redirection:

docker run --name demo-streams2 alpine:3.20 \
  sh -c 'echo "normal output"; echo "an error" >&2'
docker logs demo-streams2 2>/dev/null      # stdout only
docker logs demo-streams2 1>/dev/null      # stderr only
docker rm demo-streams2
normal output
an error

And from that comes the rule that governs all container operations:

An application in a container writes its logs to standard output and standard error. Never to a file.

The reasons:

If it logs to stdout/stderr If it logs to a file
docker logs works docker logs is empty and it looks as if the app does nothing
Logs get collected automatically You have to get in with exec or pull them out with docker cp
The file does not grow inside the writable layer The container swells until it fills the host's disk
Any aggregator (Loki, ELK, CloudWatch) collects them with no configuration You have to mount volumes and deploy agents
They are deleted along with the container They are left orphaned

Your server.js has been doing this right since lesson 01-07: it uses console.log and console.error, which in Node write to stdout and stderr respectively.

And a practical check for when docker logs is suspiciously empty:

docker exec aurora-db ls -la /proc/1/fd/1 /proc/1/fd/2
lrwx------    1 root     root      64 Aug  4 19:28 /proc/1/fd/1 -> /dev/pts/0
lrwx------    1 root     root      64 Aug  4 19:28 /proc/1/fd/2 -> /dev/pts/0

If those descriptors pointed to a file instead of to the console, there would be your explanation.

A note on scope: where those logs are really stored, how to change the logging driver, how to rotate them and how to ship them to a centralized system is the content of lesson 05-06. Here we stop at reading them.

  1. docker exec: getting into the container

docker exec -it aurora-db sh
/ # psql -U aurora -d aurora_books -c "SELECT COUNT(*) FROM books;"
 count
-------
     8
(1 row)
/ # exit

You already know from lesson 03-01 that exec launches a new process inside the container's namespaces, and that this is why leaving it does not affect the service. Its options:

Option What for
-it Interactive session with a terminal
-u, --user Run as another user, typically -u root
-w, --workdir Start in a different directory
-e Add a variable for this process only
-d Launch it in the background inside the container
--privileged With extended capabilities (last resort)

Which shell to use

Base image Available shell Command
Alpine (alpine, node:22-alpine, redis:7-alpine) sh (BusyBox ash). There is no bash docker exec -it X sh
Debian/Ubuntu (node:22, postgres:16, ubuntu) bash and sh docker exec -it X bash
distroless, scratch None See section 5

If you get it wrong, the error is unmistakable:

docker exec -it aurora-cache bash
OCI runtime exec failed: exec failed: unable to start container process:
exec: "bash": executable file not found in $PATH: unknown

A trick that almost always works:

docker exec -it aurora-cache sh -c 'command -v bash || command -v sh'

Getting in as root on an unprivileged image

Your aurora-api runs with USER node since lesson 02-04. That is excellent for production and a nuisance for debugging:

docker run -d --name api-debug --env-file ~/aurora-libros/aurora.env auroralibros/aurora-api:1.2.0
docker exec -it api-debug id
docker exec -it -u root api-debug id
docker exec -it -u root api-debug sh -c 'apk add --no-cache curl && curl -s localhost:3000/health'
uid=1000(node) gid=1000(node) groups=1000(node)
uid=0(root) gid=0(root) groups=0(root),1(bin),...
{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ko","errorDb":"getaddrinfo ENOTFOUND aurora-db","errorCache":"The client is closed"}

Three things to learn from this output:

  1. -u root gives you privileges inside the container even if the image declares another user. The USER restriction protects you from the code running inside, not from whoever controls the Docker daemon.
  2. Installing tools with apk add inside a running container is legitimate for debugging and absolutely forbidden as a way of "fixing" anything: the moment you recreate the container, that curl is gone. What gets fixed, gets fixed in the Dockerfile.
  3. And there is the diagnosis handed to you on a plate: errorDb: getaddrinfo ENOTFOUND aurora-db. The /health endpoint you wrote in lesson 01-07 is doing exactly the job you designed it for.

Diagnostic commands inside the container

docker exec api-debug printenv | sort | head -8   # what configuration does it really see?
docker exec api-debug ps -o pid,comm              # what processes are there?
docker exec api-debug cat /etc/hosts              # what names does it know?
docker exec api-debug cat /etc/resolv.conf        # which DNS does it ask?
docker exec api-debug getent hosts aurora-db      # does it resolve this name?
docker exec api-debug df -h /                     # is there space left?
docker exec api-debug ls -la /app                 # is the code where I think it is?
DB_HOST=aurora-db
DB_NAME=aurora_books
DB_PASSWORD=aurora_secret
DB_PORT=5432
DB_USER=aurora
HOME=/home/node
HOSTNAME=4c8f2a1e9b73
NODE_ENV=production

PID   COMMAND
    1 node

127.0.0.1	localhost
::1	localhost ip6-localhost ip6-loopback
172.17.0.4	4c8f2a1e9b73

nameserver 127.0.0.11
options ndots:0

Look at /etc/hosts: there are three entries and none of them is called aurora-db. The command getent hosts aurora-db did not even print anything (it returned code 2, "not found"). Keep that fact: it is half the answer to case B.

  1. Debugging minimal images with no tools

A well-optimized image ships no curl, no ping, no netstat, no dig. And distroless images or ones built FROM scratch do not even ship a shell. That is excellent for security and size (lesson 05-04) and maddening when something fails.

The modern solution: an ephemeral container loaded with tools that shares the sick container's namespaces.

docker run --rm -it \
  --network container:api-debug \
  --pid container:api-debug \
  nicolaka/netshoot
                    dP            dP                           dP
                    88            88                           88
88d888b. .d8888b. d8888P .d8888b. 88d888b. .d8888b. .d8888b. d8888P
...

What each option does:

Option Effect
--network container:X The new container shares X's network stack: same localhost, same IP, same interfaces
--pid container:X Shares the process space: you see X's processes and can examine them
nicolaka/netshoot An image with curl, dig, nmap, tcpdump, netstat, ss, iperf, jq and dozens more

And now, from inside that container, you diagnose api-debug's network as if you were in it:

 ~ ❯ ss -tlnp
State   Recv-Q  Send-Q   Local Address:Port    Peer Address:Port  Process
LISTEN  0       511            0.0.0.0:3000         0.0.0.0:*

 ~ ❯ ps -ef
PID   USER     TIME  COMMAND
    1 1000      0:00 node server.js
   28 root      0:00 zsh

 ~ ❯ nslookup aurora-db
Server:		127.0.0.11
Address:	127.0.0.11:53

** server can't find aurora-db: NXDOMAIN

 ~ ❯ curl -s localhost:3000/health | jq -r .errorDb
getaddrinfo ENOTFOUND aurora-db

Four conclusions in four commands, without installing anything in the production image:

  1. ss -tlnp confirms the API is listening on 3000, on 0.0.0.0 (good: not on 127.0.0.1, which would be unreachable from outside).
  2. ps -ef shows the neighboring container's node server.js thanks to --pid container:, and also that it runs as UID 1000.
  3. nslookup aurora-db answers NXDOMAIN: Docker's internal DNS server (127.0.0.11) exists and responds, but it does not know that name.
  4. curl works even though the API image has no curl, because netshoot provides it.

That NXDOMAIN is the definitive proof. We will come back to it in case B.

docker debug

Docker Desktop includes a built-in version of this idea:

docker debug api-debug

It opens a shell with a set of tools mounted over the container without modifying it: it works even on distroless images with no shell, and when you leave, no trace is left. It requires a Pro subscription or above; the netshoot trick is free, works on any Docker and is worth knowing anyway.

docker rm -f api-debug

  1. docker inspect: the container's complete JSON

docker inspect returns everything Docker knows about a container: around 200 lines of JSON.

docker inspect aurora-db | head -20
docker inspect aurora-db | jq 'keys'
[
  "AppArmorProfile", "Args", "Config", "Created", "Driver", "ExecIDs",
  "GraphDriver", "HostConfig", "HostnamePath", "HostsPath", "Id", "Image",
  "LogPath", "MountLabel", "Mounts", "Name", "NetworkSettings", "Path",
  "Platform", "ProcessLabel", "ResolvConfPath", "RestartCount", "State"
]

The six blocks that matter:

Block Contains
.State State, PID, exit codes, OOMKilled, health
.Config What comes from the image: Env, Cmd, Entrypoint, Labels, Healthcheck, User
.HostConfig What you set on docker run: ports, memory, CPU, restart policy
.NetworkSettings IPs, networks, published ports, gateway
.Mounts Active volumes and bind mounts
.RestartCount How many times Docker has restarted it (lesson 03-07)

Reading 200 lines of JSON is not debugging. What you do is extract the specific piece of data, with --format or with jq:

What you need to know Command
State and exit code docker inspect -f '{{.State.Status}} ({{.State.ExitCode}})' X
The container's IP docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' X
Which networks it is connected to docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' X
Published ports docker inspect -f '{{json .NetworkSettings.Ports}}' X
Environment variables docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' X
One specific variable docker inspect -f '{{json .Config.Env}}' X | jq -r '.[]|select(startswith("DB_HOST"))'
Mounts docker inspect -f '{{range .Mounts}}{{.Type}}: {{.Source}} -> {{.Destination}}{{println}}{{end}}' X
Effective command docker inspect -f '{{.Path}} {{.Args}}' X
Health state docker inspect -f '{{.State.Health.Status}}' X
Last healthcheck failure docker inspect -f '{{(index .State.Health.Log 0).Output}}' X
Did the OOM killer get it? docker inspect -f '{{.State.OOMKilled}}' X
Number of restarts docker inspect -f '{{.RestartCount}}' X
Memory limit docker inspect -f '{{.HostConfig.Memory}}' X
Labels docker inspect -f '{{json .Config.Labels}}' X | jq

A complete example on the fleet:

docker inspect -f '{{.Name}} | {{.State.Status}} | {{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}' aurora-db aurora-cache
/aurora-db | running | bridge=172.17.0.2
/aurora-cache | running | bridge=172.17.0.3

Two observations that will be decisive in five minutes: both are on the bridge network and each has its own IP.

With jq you can run richer queries over the raw JSON:

docker inspect aurora-db | jq -r '.[0].Config.Env[] | select(startswith("POSTGRES"))'
docker inspect aurora-db | jq -r '.[0].Mounts[] | "\(.Type): \(.Destination)"'
POSTGRES_USER=aurora
POSTGRES_PASSWORD=aurora_secret
POSTGRES_DB=aurora_books
volume: /var/lib/postgresql/data

A security note, and it is a serious one: docker inspect shows passwords in the clear. Anyone with access to the Docker daemon can read every environment variable of every container. That is why environment variables are not a secrets mechanism; secret managers are, and they are covered in lesson 05-03.

  1. docker top: the processes inside

docker top aurora-db
UID     PID     PPID    C   STIME   TTY   TIME       CMD
70      24817   24795   0   19:28   ?     00:00:00   postgres
70      24893   24817   0   19:28   ?     00:00:00   postgres: checkpointer
70      24894   24817   0   19:28   ?     00:00:00   postgres: background writer
70      24896   24817   0   19:28   ?     00:00:00   postgres: walwriter
70      24897   24817   0   19:28   ?     00:00:00   postgres: autovacuum launcher

Two peculiarities make it special:

  • The PIDs are the host's, not the ones inside the container. docker exec aurora-db ps would show that same postgres as PID 1; here it is 24817. It is the double numbering of PID namespaces.
  • It does not require the image to have ps. The command is run by the daemon on the host. That is why it works even on distroless images.

It is good for quickly answering: how many processes are there? Are there zombie processes? Is the application spawning children it should not?

  1. docker stats: real-time consumption

docker stats --no-stream
CONTAINER ID   NAME           CPU %   MEM USAGE / LIMIT     MEM %   NET I/O           BLOCK I/O     PIDS
3f8a1c9e7b2d   aurora-db      0.02%   38.41MiB / 7.628GiB   0.49%   1.24kB / 0B       12.3MB / 8.19MB   7
9d4b7e2f1a6c   aurora-cache   0.15%   9.83MiB / 7.628GiB    0.13%   1.86kB / 1.02kB   0B / 0B           6

Without --no-stream, the view refreshes continuously like a top. Column by column:

Column What it measures How to read it
CPU % CPU percentage It can exceed 100%: 400% = four saturated cores
MEM USAGE / LIMIT Memory used / limit If you set no limit, LIMIT is all of the host's RAM. Careful
MEM % Usage relative to the limit Sustained near 100% = a candidate for the OOM killer
NET I/O Received / sent over the network A 0B on a web service means nobody is talking to it
BLOCK I/O Read / written to disk A value that grows non-stop may be a runaway log
PIDS Number of processes and threads If it grows non-stop, there is a process leak

That LIMIT of 7.628 GiB on both rows is the alarm bell you will deal with in lesson 03-07: neither container has a memory limit, so either of them can consume all the machine's RAM.

Useful formats:

docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}"
docker stats --no-stream $(docker ps -q --filter label=project=aurora-libros)

  1. docker events: the daemon's stream

docker events --since 10m --filter container=aurora-db
2026-08-04T19:28:41.882 container create 3f8a1c9e... (image=postgres:16-alpine, name=aurora-db)
2026-08-04T19:28:41.913 network connect 6b2f... (container=3f8a1c9e..., name=bridge, type=bridge)
2026-08-04T19:28:42.104 container start 3f8a1c9e... (image=postgres:16-alpine, name=aurora-db)
2026-08-04T19:41:07.221 container exec_create: psql -U aurora... 3f8a1c9e...
2026-08-04T19:41:07.238 container exec_start: psql -U aurora... 3f8a1c9e...

docker events is the record of everything the daemon does. Without --since, it stays listening live. It is the tool that answers questions no other one can:

# Who killed my container, and when?
docker events --since 1h --filter event=die --filter event=kill

# Watch live what happens while you reproduce the failure in another terminal
docker events --filter label=project=aurora-libros

# Health events only
docker events --since 30m --filter event=health_status

The most useful events: create, start, die (with its exitCode), kill, oom, health_status, restart, destroy, and the network ones connect/disconnect. An oom in this list is confirmation that it was the kernel and not you (lesson 03-07).

  1. Case A: the container exits with code 1

Symptom: a colleague tells you the API "does not start on his machine". You apply the method.

Question 1: is it running?

docker run -d --name aurora-api \
  --env-file ~/aurora-libros/aurora.env \
  -p 3000:3000 \
  auroralibros/aurora-api:1.2.0 serve.js
sleep 2
docker ps -a --filter name=aurora-api --format "table {{.Names}}\t{{.Status}}"
docker inspect -f '{{.State.Status}} / exit code {{.State.ExitCode}}' aurora-api
NAMES        STATUS
aurora-api   Exited (1) 1 second ago
exited / exit code 1

exited / exit code 1

Code 1: from lesson 03-02's table, that means the application started and failed on its own. It is not Docker (that would be 125), nor a non-existent executable (127), nor a signal (>128). So we go to the logs.

Question 2: what do the logs say?

docker logs aurora-api
node:internal/modules/cjs/loader:1215
  throw err;
  ^

Error: Cannot find module '/app/serve.js'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1212:15)
    ...
  code: 'MODULE_NOT_FOUND'

Solved in two commands and fifteen seconds. The file is called server.js, not serve.js. Where does that name come from?

Question 3: how is it configured?

docker inspect -f 'Path: {{.Path}} | Args: {{.Args}} | Image Cmd: {{.Config.Cmd}}' aurora-api
Path: node | Args: [serve.js] | Image Cmd: [serve.js]

There it is: the image's CMD (["server.js"]) was overridden from the command line by the loose argument serve.js, exactly as you learned in lesson 03-01. The image is perfect; the mistake was in the docker run.

docker rm aurora-api
docker run -d --name aurora-api --env-file ~/aurora-libros/aurora.env \
  -p 3000:3000 auroralibros/aurora-api:1.2.0

A variant of the same symptom worth recognizing, because the diagnosis is completely different:

docker run -d --name test-125 --env-file ~/aurora-libros/does-not-exist.env alpine:3.20
echo "code: $?"
docker ps -a --filter name=test-125 -q
docker: open /home/junior/aurora-libros/does-not-exist.env: no such file or directory
code: 125

The docker ps -a command returns nothing: the container was never even created. With a 125 there are no logs to look at and no container to inspect; the error is in your command line. It is the first fork in the decision tree and it saves a lot of wasted time.

  1. Case B: ECONNREFUSED on /books

Here is the case you have been dragging along since module 2. Let's solve it with the complete method.

Question 1: is it running?

docker ps --filter name=aurora --format "table {{.Names}}\t{{.Status}}"
NAMES          STATUS
aurora-api     Up 40 seconds (unhealthy)
aurora-db      Up 1 hour
aurora-cache   Up 1 hour

All three are running. It is not a startup problem.

Question 2: what do the logs say?

docker logs aurora-api
curl -s localhost:3000/books | head -c 200
[cache] not available at startup: getaddrinfo ENOTFOUND aurora-cache
[aurora-api] listening on port 3000
[aurora-api] database: aurora-db:5432/aurora_books
[aurora-api] cache: aurora-cache:6379
[/books] error: getaddrinfo ENOTFOUND aurora-db

{"error":"Could not fetch the catalog","detail":"getaddrinfo ENOTFOUND aurora-db"}

The message is clear: getaddrinfo is the name resolution function, and ENOTFOUND means the name aurora-db could not be translated into any IP address. It is not a refused connection: it is that the API does not know where to connect.

Question 3: how is it configured?

docker inspect -f '{{.Name}}: {{range $k,$v := .NetworkSettings.Networks}}network={{$k}} ip={{$v.IPAddress}}{{end}}' \
  aurora-api aurora-db aurora-cache
/aurora-api: network=bridge ip=172.17.0.4
/aurora-db: network=bridge ip=172.17.0.2
/aurora-cache: network=bridge ip=172.17.0.3

And here comes the surprise that makes the case interesting: all three are on the same network, the default bridge, with IPs from the same 172.17.0.0/16 range. It is not an isolation problem.

Check the variables, to rule them out:

docker exec aurora-api printenv | grep -E "DB_HOST|REDIS_HOST"
DB_HOST=aurora-db
REDIS_HOST=aurora-cache

Correct. The configuration is the one you wanted.

Question 4: what is happening inside?

docker exec aurora-api getent hosts aurora-db
echo "getent exit code: $?"
docker exec aurora-api cat /etc/resolv.conf
getent exit code: 2

nameserver 127.0.0.11
options ndots:0

Docker's internal DNS (127.0.0.11) is configured, but it does not resolve aurora-db. Confirm it with real tools, using the trick from section 5:

docker run --rm --network container:aurora-api nicolaka/netshoot \
  sh -c 'nslookup aurora-db; echo "---"; nc -zv 172.17.0.2 5432'
Server:		127.0.0.11
Address:	127.0.0.11:53

** server can't find aurora-db: NXDOMAIN
---
Connection to 172.17.0.2 5432 port [tcp/*] succeeded!

This is the definitive diagnosis, and it is two opposite facts in the same output:

Test Result What it proves
nslookup aurora-db NXDOMAIN The name does not resolve
nc -zv 172.17.0.2 5432 succeeded The network connectivity exists and works perfectly

In other words: aurora-api can talk to aurora-db; it simply does not know its name. The problem was never a firewall, or ports, or PostgreSQL: it is name resolution.

And the cause is a very specific Docker characteristic: the default bridge network has no internal DNS between containers. Only user-defined networks have it. Nobody had told you until now because it is exactly the subject of the next lesson.

Could you not just put the IP in directly and be done sooner?

# It would work... today
docker run -d --name api-by-ip -e DB_HOST=172.17.0.2 -e REDIS_HOST=172.17.0.3 ...

You could, and it would be a mistake. Those IPs are assigned by Docker in start-up order: reboot the machine, change the order of the containers and 172.17.0.2 will be a different one. Writing IPs by hand is building a castle on sand. The correct solution —a network of your own where names work— is the first hands-on part of lesson 03-05, and now you know exactly why you need it.

The module's mystery is closed. All that is left is to apply the solution.

  1. Case C: the healthcheck stuck on unhealthy

Symptom: docker ps shows Up 40 seconds (unhealthy) on aurora-api. What does that mean exactly, and where does that verdict come from?

docker inspect -f '{{.State.Health.Status}} | {{len .State.Health.Log}} attempts | {{.State.Health.FailingStreak}} consecutive failures' aurora-api
unhealthy | 5 attempts | 5 consecutive failures

The complete history is in .State.Health.Log, an array with the last five attempts:

docker inspect -f '{{json .State.Health}}' aurora-api | jq '.Log[-1]'
{
  "Start": "2026-08-04T20:31:12.114Z",
  "End": "2026-08-04T20:31:12.287Z",
  "ExitCode": 1,
  "Output": "Connecting to localhost:3000 (127.0.0.1:3000)\nwget: server returned error: HTTP/1.1 503\n"
}

Read it carefully, because it tells the whole story:

Field Value Interpretation
Start / End 0.173 s apart The check did not time out: it responds fast, but badly
ExitCode 1 The HEALTHCHECK command returned an error. Any value other than 0 is a failure
Output HTTP/1.1 503 The API did answer, with a 503

That 503 is no accident: you wrote it yourself in lesson 01-07. The /health endpoint returns 200 only if the database and the cache respond, and 503 in any other case:

curl -s -w "\nHTTP %{http_code}\n" localhost:3000/health
{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ko","errorDb":"getaddrinfo ENOTFOUND aurora-db","errorCache":"The client is closed"}
HTTP 503

And now the nuance that separates a real unhealthy from a false positive:

The container is healthy; the service is not. The node process is alive, listening on 3000 and responding in 173 milliseconds. What is broken are its dependencies. The HEALTHCHECK is doing exactly what you asked it to: reporting that this container is not in a fit state to serve traffic.

The four possible health states:

State When it appears
starting During the --start-period (10 s in your Dockerfile). Failures here do not count
healthy The last check returned 0
unhealthy It has failed --retries times in a row (3 in your case)
(none) The image defines no HEALTHCHECK — the case of postgres:16-alpine

And the detail that surprises everyone: unhealthy does nothing on its own. Docker Engine does not restart the container, does not take it out of rotation and does not notify anyone; it just marks it and emits a health_status event. Who acts on that information is an orchestrator (Swarm in lesson 06-03, Kubernetes in 06-05) or you, looking at docker ps. Not even the --restart policy reacts to an unhealthy, and that nuance is explained in lesson 03-07.

docker events --since 5m --filter event=health_status --filter container=aurora-api
2026-08-04T20:31:12.311 container health_status: unhealthy 4c8f2a1e9b73 (name=aurora-api)

Leave aurora-api as it is: in the next lesson it will turn healthy on its own.

Common Mistakes and Tips

  • Starting by getting into the container. 70% of breakdowns show up in docker logs in ten seconds. Follow the order: state → logs → configuration → inside.
  • Debugging with --rm. If the container is deleted when it dies, there are no logs, no inspect and no autopsy. During an investigation, no --rm.
  • Looking for the logs of an application that writes to a file. An empty docker logs does not mean "nothing is happening": check where the process is really writing.
  • Installing tools in the container "to fix it". They are lost when you recreate it. To diagnose, an ephemeral container with netshoot; to fix, the Dockerfile.
  • Confusing ECONNREFUSED with ENOTFOUND. The first says "the name resolved but nobody is listening there"; the second, "I do not know who that is". They are two different breakdowns and they lead to different places.
  • Reading docker stats with no limits configured. The LIMIT column shows all the host's RAM and the MEM % is deceptively reassuring.
  • Believing unhealthy restarts anything. Docker Engine only marks it. Without an orchestrator, nobody acts.
  • Dumping the whole docker inspect into the terminal. That is 200 lines of JSON. Use --format or jq and go straight to the fact.
  • Tip: keep aliases for the inspect queries you repeat. For example dip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$1"; }.
  • Tip: docker events in a second terminal while you reproduce the failure. Seeing the die with its exitCode at the exact instant is worth more than ten guesses.

Exercises

Exercise 1: autopsy of a dead container

Launch this container, which will fail on purpose, and carry out the complete investigation without running it again:

docker run -d --name autopsy -e MODE=production alpine:3.20 \
  sh -c 'echo "[init] starting in $MODE mode"; echo "[init] API_KEY variable is missing" >&2; sleep 2; exit 78'

Answer with specific commands: what state is it in and what code did it exit with? Does that code point at Docker or at the application? What did it write to stdout and what to stderr, separately? What environment variables did it have configured? What was its effective command? How long was it alive (work it out with StartedAt and FinishedAt)?

Exercise 2: diagnose a network with no tools

Start a web-silent container with nginx:alpine without publishing any port. Then, without installing anything inside it and without recreating it:

  1. Find out its IP address.
  2. Check from an ephemeral netshoot container that Nginx responds on its port 80.
  3. Prove that from the host, with curl localhost:80, it does not respond, and explain why that does not contradict the previous point.
  4. Find out what processes run inside without using docker exec.

Exercise 3: the healthcheck that lies

Create an aurora-api:fake-healthy image from auroralibros/aurora-api:1.2.0 that replaces the HEALTHCHECK with one that simply checks the process exists (CMD pgrep node || exit 1). Start it with no database and no cache and compare, with commands, the health state it reports against that of auroralibros/aurora-api:1.2.0 under the same conditions. Then answer: which of the two healthchecks is "better"? In what specific situation would the second one have saved you from an incident, and in which one would it have given you a false alarm?

Solutions

Solution to exercise 1

docker ps -a --filter name=autopsy --format "table {{.Names}}\t{{.Status}}"
docker inspect -f '{{.State.Status}} / exit code {{.State.ExitCode}}' autopsy
NAMES     STATUS
autopsy   Exited (78) 30 seconds ago
exited / exit code 78

Code 78 is in the 1–124 range, so it is the application's, not Docker's. A 125 would have meant an error in docker run itself, a 127 a non-existent executable and a 137 a signal. Here, the program decided to exit with that number, so the explanation is in its code and in its logs.

echo "--- stdout ---"; docker logs autopsy 2>/dev/null
echo "--- stderr ---"; docker logs autopsy 1>/dev/null
--- stdout ---
[init] starting in production mode
--- stderr ---
[init] API_KEY variable is missing

Separating the streams is what gives the diagnosis: stdout has informational noise and stderr has the real cause. In a 500-line mixed-up log, that filter is the difference between finding it and not finding it.

docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' autopsy
docker inspect -f 'Path: {{.Path}} | Args: {{.Args}}' autopsy
MODE=production
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

Path: sh | Args: [-c echo "[init] starting in $MODE mode"; echo "[init] API_KEY variable is missing" >&2; sleep 2; exit 78]

Confirmed: MODE was defined and API_KEY is nowhere to be seen, which is exactly what stderr was complaining about.

docker inspect -f 'Started:  {{.State.StartedAt}}{{println}}Finished: {{.State.FinishedAt}}' autopsy
docker rm autopsy
Started:  2026-08-04T20:52:03.417218Z
Finished: 2026-08-04T20:52:05.583904Z

It lived 2.17 seconds, consistent with the sleep 2 in the command. That calculation is more useful than it looks: a container that lives for milliseconds usually fails at startup; one that lives for hours and then dies points to a memory leak or an external event.

Solution to exercise 2

docker run -d --name web-silent nginx:alpine
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web-silent
172.17.0.5

2. Checking that it responds, from netshoot:

docker run --rm nicolaka/netshoot curl -s -o /dev/null -w "HTTP %{http_code}\n" http://172.17.0.5
HTTP 200

Nginx works perfectly. Another way, sharing its network stack directly:

docker run --rm --network container:web-silent nicolaka/netshoot \
  sh -c 'ss -tlnp; curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost'
State  Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0      511          0.0.0.0:80        0.0.0.0:*
HTTP 200

3. From the host:

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

There is no contradiction at all: the container listens on port 80 of its own network stack, but since no port was published with -p, there is no rule forwarding the host's port 80 to it. The connectivity exists inside Docker's network (where netshoot lives) and does not exist from the host. It is exactly the distinction between "publishing a port" and "two containers talking to each other" that gets formalized in lesson 03-05.

4. Processes without docker exec:

docker top web-silent
UID     PID     PPID    C   STIME   TTY   TIME       CMD
root    26104   26082   0   21:03   ?     00:00:00   nginx: master process nginx -g daemon off;
101     26155   26104   0   21:03   ?     00:00:00   nginx: worker process

docker top is run by the daemon on the host, so it does not need to get into the container or for the image to have ps. And as a bonus you can see Nginx's good practice: the master runs as root and the workers as user 101.

docker rm -f web-silent

Solution to exercise 3

# ~/aurora-libros/api/Dockerfile.fake-healthy
FROM auroralibros/aurora-api:1.2.0
# pgrep ships with BusyBox, so nothing needs installing
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD pgrep node || exit 1
docker build -f ~/aurora-libros/api/Dockerfile.fake-healthy -t aurora-api:fake-healthy ~/aurora-libros/api
docker run -d --name api-fake-healthy --env-file ~/aurora-libros/aurora.env aurora-api:fake-healthy
sleep 45
docker ps --filter name=api --format "table {{.Names}}\t{{.Status}}"
NAMES              STATUS
api-fake-healthy   Up 45 seconds (healthy)
aurora-api         Up 15 minutes (unhealthy)

Two containers identical in everything except their health check, and opposite verdicts. The one on the left says it is healthy; neither of the two can serve a single book.

docker inspect -f '{{json .State.Health}}' api-fake-healthy | jq '.Log[-1] | {ExitCode, Output}'
curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:3000/health
{ "ExitCode": 0, "Output": "1\n" }

The fake healthcheck answers 0 because pgrep node finds the process. And it is entirely right: the process is alive. It is simply measuring what does not matter.

The answers:

  • The one in auroralibros/aurora-api:1.2.0 is better in this scenario, because it checks the real ability to provide service (an end-to-end HTTP request, which in turn queries the database and the cache) instead of the mere existence of a process.
  • When the second one (pgrep) would have saved you: when the outage is in a dependency and you do not want all your replicas to declare themselves sick at once. If PostgreSQL goes down for 30 seconds, with the strict healthcheck the API's ten replicas all switch to unhealthy simultaneously, the orchestrator takes them out of rotation and you end up with a total outage instead of a degraded service still serving cached content. It is the well-known domino effect of healthchecks that are too deep.
  • When it would have given you a false alarm... or worse, none at all: when the Node process is alive but its event loop is blocked, its connection pool exhausted or it answers 500 to everything. pgrep would say "healthy" indefinitely while users see errors. That scenario is literally the one that motivated HEALTHCHECK in lesson 02-04.

The professional solution, which you were already sketching in exercise 3 of lesson 02-04, is to separate two endpoints: one for liveness (/health/live, shallow, for deciding on restarts) and another for readiness (/health, deep, for deciding whether it receives traffic). It is the liveness/readiness distinction developed in lesson 06-05.

docker rm -f api-fake-healthy
docker image rm aurora-api:fake-healthy

Conclusion

You have a method, and that is worth more than the list of commands: is it running? → what do the logs say? → how is it configured? → what is happening inside?, in that order and without skipping steps. You know that a Created points to a non-existent executable, that a 125 means the container was never created and there is nothing to inspect, and that a code between 1 and 124 sends you straight to docker logs.

You handle docker logs with its time windows, its --tail and its -f that you can cut with Ctrl+C without fear, and you have the golden rule engraved: containers log to stdout and stderr, never to a file, and you know how to separate the two streams with a redirection so the error appears on its own. You get in with docker exec choosing the right shell for the base, you know -u root gives you privileges even if the image declares USER node, and —most valuable of all— you know how to debug minimal images with no tools by launching an ephemeral netshoot container that shares the sick one's network and process namespaces, without dirtying the production image.

From docker inspect's JSON you extract the exact fact with Go templates and jq, with a table of queries for IP, networks, ports, mounts, environment, health, exit code and restarts, and you know that same command shows passwords in the clear, which is why environment variables are not a secrets mechanism. With docker top you see the processes from the host even if the image has no ps, with docker stats you read the seven columns —and you have discovered your containers have no memory limit at all— and with docker events you reconstruct what happened and when.

And you have closed the investigation you had been dragging along since module 2. aurora-api, aurora-db and aurora-cache are on the same network and with full connectivity between them: nc -zv 172.17.0.2 5432 answers succeeded. Only one thing fails, and now you can name it precisely: nslookup aurora-db returns NXDOMAIN, because the default bridge network has no internal DNS between containers. It is not a firewall, or a port, or PostgreSQL: it is that the API does not know its database's name.

All that is left is to apply the solution, and it is shorter than the diagnosis. In the next lesson, Docker Networking, you will see why each container has its own network stack and its own localhost, you will compare the bridge, host and none drivers, and you will understand the decisive difference between the default bridge and a user-defined network, where internal DNS resolves container names automatically. You will create aurora-net, recreate the three services inside it, and finally run that curl http://localhost:3000/books that has been waiting six lessons to give you back El jardín de senderos que se bifurcan, Rayuela and the other six titles. Then you will add aurora-web as a reverse proxy, and the Aurora Libros platform will be truly alive.

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