Four symptoms cover most of what goes wrong with a container: it exited, it says
unhealthy, it cannot reach something, or it is behaving like a version of your code
that is not the one you just built. Each has a reliable way in.
Logs
docker compose logs api # everything so far
docker compose logs -f api # follow
docker compose logs --tail 50 api # just the end
docker compose logs -t api # with timestamps
docker compose logs # every service, interleaved and colour-codedWhat logs shows is stdout and stderr of PID 1. Nothing else.
That is worth stating plainly, because it explains the common case of an application that runs
fine and logs nothing: it was configured to write to a file. In a container the convention is to log
to stdout and let the platform deal with collection — logging.file.name unset in Spring
Boot, the default in most frameworks. If your app insists on a file, the usual workaround is to
symlink it to /dev/stdout.
Note also that docker compose down removes the containers, and their logs with them.
Read them before you tear the stack down.
"It exited"
$ docker compose ps -a
NAME STATUS
pizza-api-1 Exited (1) 4 seconds agoThe number in brackets is the process's exit code, and it is the first real clue:
0 finished successfully — usually "the command had nothing to do"
1 the application threw
125 docker itself failed (a bad flag)
126 the command was found but is not executable
127 command not found — a typo, or a binary that is not in this image
137 SIGKILL — usually the OOM killer, or `docker stop` timing out
139 SIGSEGV
143 SIGTERM — a normal `docker stop`137 is the one worth recognising on sight. It usually means the container hit its memory limit and the kernel killed it. Nothing in the application's own logs will mention this, because the process was not asked — it was shot. Confirm it:
$ docker inspect pizza-api-1 --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
true 137Exit 0 with no error at all is the other classic, and it is not a fault — see lesson 2. A container lives exactly as long as its main process. If that process starts a daemon and returns, the container exits immediately, however healthy the daemon was.
Getting inside
docker compose exec api shexec starts a second process in a running container. Two things that trip
people:
sh, not bash. Alpine images have no bash, and the error
— executable file not found — reads like the container is broken rather than
minimal.
Many of your tools are not in there. A slim image has no curl, no
ps, no dig. wget is usually present in Alpine;
getent hosts covers DNS.
And exec is no use at all on a container that has already exited. For that:
docker run --rm -it --entrypoint sh pizza-api:devSame image, same filesystem, but a shell instead of your program — so you can check whether the file you expected is actually where you expected it.
What configuration did it actually get?
Usually more informative than reading the compose file again, because it shows what was resolved:
docker inspect pizza-api-1 --format '{{json .Config.Env}}' | tr ',' '\n'
docker inspect pizza-api-1 --format '{{json .Mounts}}'
docker inspect pizza-api-1 --format '{{json .NetworkSettings.Networks}}'
docker compose config # the merged, interpolated compose filedocker compose config is the one to reach for when ${VAR} interpolation
or a -f override is involved. It prints exactly what Compose is going to act on, with
every variable substituted.
Resources
$ docker stats --no-stream
CONTAINER CPU % MEM USAGE / LIMIT MEM %
pizza-api-1 0.31% 412MiB / 1.914GiB 21.02%
pizza-mysql-1 0.52% 448MiB / 1.914GiB 22.86%If MEM USAGE is pinned against LIMIT and the container keeps restarting,
you have found your 137.
The healthcheck that lies
A container marked unhealthy that is serving perfectly well is a specific and
memorable failure, and the demo app's development compose file has a comment about it because it
cost real time.
The rule: health-check with a command the container actually has. A
curl-based check on an image with no curl fails every time, forever — and
what you see is a service that never becomes healthy, which looks exactly like a service that never
started. Meanwhile up --wait blocks and then blames the wrong thing.
healthcheck:
test: ["CMD", "/var/lib/artemis-instance/bin/artemis", "check", "node", "--up",
"--user", "admin", "--password", "admin"]
interval: 10s
timeout: 10s
retries: 20
start_period: 30sArtemis ships its own probe, and unlike an HTTP poke at the web console it checks the messaging port — the thing clients actually connect to. Check what you depend on, with a tool that is present.
When one is failing, the output is recorded:
docker inspect pizza-mysql-1 --format '{{json .State.Health}}'That gives you the last few probe runs with their exit codes and output — which turns "unhealthy" into an actual error message.
"My change did not take effect"
Almost always one of three things.
Compose did not rebuild. docker compose up -d reuses an existing
image; it does not notice that a source file changed. Use up -d --build.
You changed a build arg. Anything compiled into a bundle needs a rebuild, not a restart — lesson 12.
The layer cache reused something you did not expect. Rare, but
--no-cache settles the question in one command.
docker compose up -d --build
docker compose build --no-cache api && docker compose up -d apiA working order
When a service will not come up, work outward rather than guessing:
docker compose ps -a # 1. is it running? what exit code?
docker compose logs --tail 50 api # 2. what did it say on the way out?
docker inspect pizza-api-1 --format '{{json .State.Health}}' # 3. healthcheck output
docker compose exec api sh # 4. look around inside
docker compose exec api sh -c 'getent hosts mysql' # 5. can it see its dependency?
docker compose config # 6. is the config what you think?