Skip to content

Exec and Logs

Measured on Docker Desktop 4.81.0 / Engine 29.6.1 (macOS, linux/arm64), image nginx:1.31-alpine, 2026-09-30.

docker exec runs a new process inside an already-running container. The most common use is opening an interactive shell:

Terminal window
docker exec -it <container> sh
  • -i keeps stdin open (interactive)
  • -t allocates a pseudo-TTY (so you get a proper terminal prompt)
  • sh is the command to run — use bash only if the image has it. Alpine-based images (nginx:1.31-alpine, node:24-alpine, alpine) ship BusyBox sh but not bash.

Inside the shell you can inspect files, run commands, and debug problems — exactly as if you were on the host machine.

Terminal window
# Example session
docker run -d --name webserver nginx:1.31-alpine
docker exec -it webserver sh
# Inside the container:
# / # cat /etc/os-release
# / # ls /usr/share/nginx/html
# / # exit

Choosing the user, directory and environment

Section titled “Choosing the user, directory and environment”

docker exec can override three things for the new process only (the container’s own environment is not changed):

$ docker exec -u nginx -w /etc/nginx -e GREETING=hello webserver sh -c 'id -un; pwd; echo $GREETING'
nginx
/etc/nginx
hello

Without -u the process runs as the container’s configured user (root for this image). -w sets the working directory and -e adds a variable for this one process. Exec’d processes see the same filesystem and the same network as PID 1; they are not visible in docker ps.

What the shell can’t do when the image has no shell

Section titled “What the shell can’t do when the image has no shell”
$ docker exec webserver bash -c 'echo hi'
OCI runtime exec failed: exec failed: unable to start container process: exec: "bash": executable file not found in $PATH
$ echo $?
127
$ docker exec webserver sh -c 'echo hi'
hi

127 is the “command not found” code from the lifecycle lesson. Minimal images (distroless, scratch) have no sh either; there docker exec cannot open a shell at all and you debug with docker logs, docker cp, or a debug sidecar.

Every container’s stdout and stderr are captured by a logging driver. The default is json-file (docker info --format '{{.LoggingDriver}}' printed json-file):

Terminal window
# Print all logs so far
docker logs webserver
# Follow (stream) new log lines in real time — like tail -f
docker logs -f webserver
# Show the last 20 lines
docker logs --tail 20 webserver
# Add timestamps
docker logs -t webserver
# Only lines from the last 30 seconds
docker logs --since 30s webserver

Real output of docker logs --tail 2 -t (RFC 3339 timestamps, UTC):

2026-09-30T08:29:16.234130419Z 2026/09/30 08:29:16 [notice] 1#1: start worker process 39
2026-09-30T08:29:16.234618711Z 2026/09/30 08:29:16 [notice] 1#1: start worker process 40

With a container that prints tick N once a second, docker logs --since 2s returned the last three ticks and --tail 1 only the last. Logs are the first place to look when a container misbehaves. If a container keeps restarting, docker logs <name> usually tells you exactly why.

json-file has no size limit unless you set one: docker inspect -f '{{.HostConfig.LogConfig.Config}}' printed map[]. A chatty container fills the disk that holds /var/lib/docker (inside the VM on Docker Desktop; docker inspect -f '{{.LogPath}}' prints the path). Rotate with --log-opt:

scripts/log-rotation.sh
#!/bin/sh
docker run --name noisy --log-opt max-size=1k --log-opt max-file=2 alpine:3.24 \
sh -c 'for i in $(seq 1 200); do echo "line $i"; done'
docker logs noisy | wc -l
docker logs noisy | head -1
docker rm noisy

Real result: the same loop with rotation kept 18 of 200 lines (from line 183 to line 200); the identical container without --log-opt kept all 200. The config is stored on the container: json-file map[max-file:2 max-size:1k]. In practice use something like max-size=10m max-file=3, or set it for every container in daemon.json (log-driver, log-opts), per the json-file driver docs. Note that rotated-away lines are gone: a limit is a trade of history for disk.

docker top lists the processes running inside a container — without entering it:

Terminal window
docker top webserver

Real output from this Mac:

UID PID PPID C STIME TTY TIME CMD
root 60551 60527 0 08:29 ? 00:00:00 nginx: master process nginx -g daemon off;
statd 60587 60551 0 08:29 ? 00:00:00 nginx: worker process
statd 60588 60551 0 08:29 ? 00:00:00 nginx: worker process
... # 11 workers in total

Three things differ from the tidy sample you may see elsewhere:

  • The PIDs are the host’s (here the Desktop VM’s), not the container’s. docker exec webserver ps shows the same processes as PID 1 (master) and 30, 31, … (workers) — the PID namespace at work.
  • The user column is statd, not nginx: docker top resolves the numeric uid (101) with the host’s /etc/passwd, where uid 101 happens to be statd. Inside the container the same uid is nginx. Never trust the name in docker top for security decisions; look at the uid.
  • There are 11 workers, one per CPU the VM has (worker_processes auto), not one.

You can pass ps options: docker top webserver -o pid,user,args.

You do not always need a full shell. docker exec can run any command directly:

Terminal window
# Check nginx config
docker exec webserver nginx -t
# View a file
docker exec webserver cat /etc/nginx/nginx.conf

nginx -t printed syntax is ok / test is successful.

  • docker exec starts a new process. Leaving it (or Ctrl+C in it) ends only that process.
  • docker attach connects your terminal to the container’s existing PID 1 stdin/stdout/stderr. Signals go to PID 1.

Proven with a container whose PID 1 is a while true; do echo tick; sleep 1 loop: docker attach streamed tick 3, 4, 5…; sending SIGINT to the attach client (what Ctrl+C does) was proxied to PID 1 by default (--sig-proxy=true), and the container ended exited 130 (128 + 2, SIGINT). That is why the docs advise detaching with Ctrl-p Ctrl-q or attaching with --sig-proxy=false if you only want to look (docker attach reference).

# 1. Start nginx in the background
docker run -d --name webserver nginx:1.31-alpine

# 2. Check its logs
docker logs webserver

# 3. Run a one-off command inside it
docker exec webserver nginx -t

# 4. Open an interactive shell
#    (type 'exit' when done)
docker exec -it webserver sh

# 5. Watch processes
docker top webserver

# 6. Follow logs in real time (Ctrl+C to stop)
docker logs -f webserver

An AI-written “debug helper” for a team wiki:

🐛 Spot the bug

#!/bin/sh
# AI: "open a shell in the api container (works for any image)"
docker exec -it api bash
Show the answer

The container is nginx:1.31-alpine (or node:24-alpine, or any Alpine image), which has no bash. Proven above: OCI runtime exec failed: ... exec: "bash": executable file not found in $PATH, exit code 127. The same helper also breaks in automation: with stdin not a terminal, -t is refused (echo hi | docker exec -it web sh printed cannot attach stdin to a TTY-enabled container because stdin is not a terminal, exit 1), while -i alone worked and printed hi. Pick the shell that exists and only ask for a TTY when there is one:

scripts/shell.sh
#!/bin/sh
set -eu
c=${1:?usage: shell.sh <container>}
# Prefer bash if present, else fall back to sh; only allocate a TTY on a terminal
sh_bin=$(docker exec "$c" sh -c 'command -v bash || command -v sh')
if [ -t 0 ]; then
exec docker exec -it "$c" "$sh_bin"
else
exec docker exec -i "$c" "$sh_bin"
fi

If the image has no shell at all, docker exec fails the same way; use docker logs, docker cp, or run a debug container in the same network/PID namespace.

A running container is one PID 1 plus whatever else the kernel puts in its namespaces. logs reads what PID 1 wrote to stdout/stderr (stored by the logging driver, outside the container’s filesystem). exec adds a process to the same namespaces. top looks at the same processes from the outside, with the host’s PIDs and the host’s user names. attach wires your terminal to PID 1 itself.

OptionBenefitCost
docker exec -it live debuggingImmediate real state, fast for on-the-spot troubleshootingNot persisted, no audit trail, and easy to forget what you changed
docker logs + centralized loggingKeeps history, searchable and shareable with the teamNeeds a logging driver or log aggregator set up
json-file with max-size/max-fileBounded disk useOld lines are discarded (we kept 18 of 200)
json-file unbounded (default)Nothing is lostA chatty container can fill the disk
  • Using docker exec to patch files live inside a container instead of fixing the image or Dockerfile and rebuilding, so the change disappears the moment the container is removed.
  • Jumping straight to docker exec to debug before checking docker logs, when the logs usually already say exactly what went wrong.
  • Relying on docker exec as the primary debugging tool instead of proper logging/monitoring — it only works while the container is alive; a crashed container can no longer be exec’d into.
  • Copy-pasting docker exec -it x bash for images that only have sh.
  • Leaving json-file unbounded on a long-lived, verbose container.

The same split exists in Kubernetes: kubectl logs reads what the container wrote, kubectl exec runs a new process in it (kubectl reference). Different tool, same model.

  • “docker exec starts another container.” It adds a process to the existing one; the container’s docker ps line does not change.
  • “The PIDs in docker top are the container’s PIDs.” They are the host’s. Inside, the master is PID 1.
  • “Docker keeps only recent logs.” Not by default: json-file keeps everything until the container is removed, unless you set max-size.
  • “Ctrl+C in docker attach is harmless.” It is proxied to PID 1; the container exited 130.
What does the -t flag do in 'docker exec -it container sh'?
Which command streams new log lines from a container in real time?
What happens to the container when you 'exit' the shell opened by docker exec?
Which command lists the processes running inside a container without opening a shell?
docker exec -it web bash fails on an Alpine-based nginx container. What is the error and fix?
A container prints 200 lines with --log-opt max-size=1k --log-opt max-file=2. How many does docker logs return, compared with no --log-opt?