Docker – Logs, Exec and Debugging a Container

July 21, 20265 min readUpdated 8/21/2026

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-coded

What 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 ago

The 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 137

Exit 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 sh

exec 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:dev

Same 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 file

docker 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: 30s

Artemis 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 api

A 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?

Next: running more than one of these at a time.