3. Working with Containers
This chapter is about operating containers day to day: creating, starting, stopping, and removing them; getting a shell, files, and logs in and out of them; and configuring how they run through environment variables, ports, users, and entrypoints. It closes with the tools you use to see what is running, what it consumes, and what can be cleaned up. Almost every later topic (Compose, networking, debugging, production) is built on these commands, so fluency here pays off throughout the rest of the guide.
<a id="31-container-lifecycle"></a>
3.1 Container Lifecycle
This section follows a container from creation to removal and explains every state it can be in along the way.
docker run
Why it matters. docker run is the command you will type more than any other. It looks like a single action, but it actually combines several steps, and its flags control nearly everything about the resulting container. Knowing what it does under the hood explains many confusing behaviors, such as why a container "disappears" right after starting, or why flags placed after the image name are ignored.
What it does. docker run IMAGE is shorthand for:
docker run [OPTIONS] IMAGE [COMMAND] [ARG...]
|
+--> 1. pull image not found locally? download it (controlled by --pull)
+--> 2. create writable layer + configuration, container gets an ID and a name
+--> 3. connect attach it to a network, allocate an IP, set up published ports
+--> 4. start runc starts the main process (ENTRYPOINT + CMD) as PID 1
+--> 5. attach (foreground only) stream stdout/stderr to your terminal
The container lives exactly as long as its main process. When that process exits, the container stops, regardless of whether other processes were started with docker exec. A container running echo hi therefore starts, prints, and exits within milliseconds; that is expected, not a failure.
Argument order matters. Everything before the image name is an option for Docker; everything after it is the command and arguments for the container.
docker run --rm -e MODE=dev python:3.13-slim python -c "print('hi')"
\_______________/ \______________/ \____________________/
Docker options image command (replaces the image's CMD)
Most-used options
| Option | Purpose | Covered in |
|---|---|---|
-d, --detach | Run in the background, print the container ID | Interactive and Detached Modes |
-i, -t | Keep STDIN open, allocate a terminal | Interactive and Detached Modes |
--rm | Remove the container automatically when it exits | Removing Containers |
--name | Give the container a fixed name | Naming Containers |
-e, --env-file | Set environment variables | Environment Variables |
-p, -P | Publish container ports on the host | Port Publishing |
-v, --mount | Attach volumes and bind mounts | 6. Data Persistence and Storage |
--network | Choose the network | 7. Docker Networking |
--restart | Restart policy | Restart Policies |
-w, -u | Working directory and user | Working Directory and User |
--entrypoint | Replace the image's entrypoint | Entrypoint and Command Overrides |
--memory, --cpus | Resource limits | 11. Resource Management and Performance |
--pull | missing (default), always, or never | 4. Docker Images |
Example
# One-shot command: runs, prints, exits; --rm deletes the stopped container
docker run --rm alpine:3.20 echo "hello from $(hostname)"
# hello from my-laptop <- $(hostname) was expanded by YOUR shell, before Docker ran
# Quote the command so the container's shell expands it instead
docker run --rm alpine:3.20 sh -c 'echo "hello from $(hostname)"'
# hello from 4c1e8a2f7b3d <- the container's hostname defaults to its short ID
# Long-running service in the background
docker run -d --name web -p 8080:80 nginx:1.27-alpine
# 9f2c5e1a... <- full container ID
curl -s localhost:8080 | head -n 4
# Common mistake: options after the image are passed to the container, not to Docker
docker run alpine:3.20 --rm echo hi
# docker: Error response from daemon: ... exec: "--rm": executable file not found in $PATH
# Always pull the newest build of the tag before running
docker run --rm --pull always alpine:3.20 cat /etc/alpine-release
Key takeaways
docker run= pull (if needed) + create + start + attach.- A container runs as long as its main process; when PID 1 exits, the container stops.
- Options go before the image name; anything after it replaces the image's default command.
- Use
--rmfor throwaway containers so stopped containers do not pile up.
🧪 Practice
- (Beginner) Run
docker run ubuntu:24.04with no command. Usedocker ps -ato find out what happened and explain why the container exited immediately.- (Beginner) Run
docker run --rm -d nginx:alpineanddocker run --rm nginx:alpinein turn. Describe the difference in what your terminal shows and how you stop each.- (Intermediate) Run
docker events --filter type=containerin a second terminal, thendocker run --rm alpine true. Match every event to one of the five steps in the diagram above.
Creating vs Starting Containers
Why split creation and start. Sometimes you want the container to exist before it runs: to copy configuration into it, to inspect the configuration Docker computed, to have a supervisor start it later, or to extract files from an image without running any of its code. Docker exposes the two halves of docker run as separate commands for exactly these cases.
What each step does
| Command | Result | State afterwards |
|---|---|---|
docker create | Allocates the ID, name, writable layer, and stores the full configuration | created |
docker start | Sets up networking, starts the main process | running (or exited if it finishes) |
docker run | Both of the above, plus attach | running |
Configuration is fixed at creation. Image, command, environment variables, ports, mounts, and network settings are recorded when the container is created. docker start has no options to change them. A few runtime properties can be changed later with docker update (memory and CPU limits, restart policy), but for anything else you must remove and recreate the container. This is why containers are treated as disposable: you replace them rather than reconfigure them.
Restarting is not re-creating. docker start on an exited container resumes the same container: same ID, same writable layer, same configuration. Files written inside it are still there. docker restart is simply stop followed by start.
Example
# Create without starting
docker create --name cfg-demo -p 8081:80 nginx:1.27-alpine
docker ps -a --filter name=cfg-demo --format '{{.Names}}: {{.Status}}'
# cfg-demo: Created
# Inject a page before the first start (works on created and stopped containers)
echo '<h1>prepared before start</h1>' > index.html
docker cp index.html cfg-demo:/usr/share/nginx/html/index.html
docker start cfg-demo
curl -s localhost:8081
# <h1>prepared before start</h1>
# Start with your terminal attached to the output (-a) and stdin (-i)
docker create --name once alpine:3.20 echo "ran"
docker start -a once # ran
docker start -a once # ran <- same container, executed again
# Extract a file from an image without running it
docker create --name extract node:22-alpine
docker cp extract:/usr/local/bin/node ./node-binary
docker rm extract
# Change what can be changed in place
docker update --restart unless-stopped --memory 256m --memory-swap 512m cfg-demo
Key takeaways
docker createprepares a container;docker startruns it;docker rundoes both.- Configuration is frozen at creation; only resource limits and the restart policy can be changed with
docker update. - Starting an exited container reuses its writable layer and ID; it is not a fresh container.
docker create+docker cpis a clean way to extract files from an image.
🧪 Practice
- (Beginner) Create a container from
alpinewith the commanddate, start it three times withdocker start -a, and confirm withdocker ps -athat only one container exists.- (Intermediate) Create an nginx container, change its restart policy and memory limit with
docker update, and verify both withdocker inspect. Then try to change its published port and describe what you must do instead.- Interview question: "Why can't you add a port mapping or an environment variable to an existing container?" Hint: think about when Docker sets up the namespaces, iptables rules, and process environment, and about the "replace, don't modify" philosophy.
Stopping and Killing Containers
Why graceful shutdown matters. A web server may be in the middle of handling requests; a database may have data in memory that has not yet been written to disk. Cutting power to such a process risks failed requests or corrupted data. Docker therefore offers two ways to end a container: politely asking it to finish (stop) and forcibly ending it (kill).
How docker stop works
docker stop web
|
+--> send STOPSIGNAL to PID 1 (default SIGTERM; images can change it, e.g. nginx uses SIGQUIT)
|
+--> wait up to the timeout (default 10 s; --timeout / -t, or --stop-timeout at run time)
| |
| +-- process exits in time --> container "Exited (0)" (or the app's own exit code)
|
+--> timeout reached: send SIGKILL --> container "Exited (137)"
docker kill skips the polite phase and sends SIGKILL immediately, or any signal you choose with --signal. Sending a non-fatal signal is a common way to ask a process to reload its configuration (for example SIGHUP to many daemons).
Analogy. docker stop is announcing "the store closes in ten minutes" and then locking the doors; docker kill is locking the doors right now.
The PID 1 trap. The kernel protects PID 1 in a namespace: signals for which PID 1 has not installed a handler are ignored (except SIGKILL sent from outside). Many applications were never written to run as PID 1. If your image starts the app through a shell (CMD node server.js in shell form makes /bin/sh -c PID 1), the shell receives SIGTERM and does not forward it. The result is the classic "every docker stop takes exactly 10 seconds and exits with 137" symptom. Fixes: use exec-form CMD/ENTRYPOINT, handle SIGTERM in the app, or run with --init to put a tiny init process (tini) in front of the app. See 14.3 Production Best Practices for details.
Exit codes you will see
| Exit code | Meaning |
|---|---|
0 | Process exited cleanly (handled the signal) |
137 | 128 + 9: killed by SIGKILL (stop timeout, docker kill, or the OOM killer) |
143 | 128 + 15: terminated by SIGTERM without handling it |
Example
docker run -d --name web nginx:1.27-alpine
time docker stop web # returns almost immediately: nginx handles its stop signal
docker inspect -f '{{.State.ExitCode}}' web # 0
# A process that ignores SIGTERM as PID 1: sleep has no handler
docker run -d --name stubborn alpine:3.20 sleep 1000
time docker stop stubborn # real 0m10.3s -> waited the full timeout
docker inspect -f '{{.State.ExitCode}}' stubborn # 137
# Same process behind tini (--init): the signal is handled and stop is fast
docker run -d --name polite --init alpine:3.20 sleep 1000
time docker stop polite # real 0m0.4s
docker inspect -f '{{.State.ExitCode}}' polite # 143 (tini forwarded SIGTERM, sleep died of it)
# Custom timeout: give a database up to 60 s to shut down cleanly
docker stop --timeout 60 mydb
# Kill immediately, or send a specific signal
docker kill stubborn
docker kill --signal SIGHUP web # ask a running process to reload (container keeps running)
docker rm stubborn polite web
Key takeaways
docker stopsends the stop signal (defaultSIGTERM), waits (default 10 s), then sendsSIGKILL.docker killsendsSIGKILLimmediately, or any signal via--signal.- A 10-second stop that ends in exit code 137 usually means PID 1 ignored
SIGTERM. - Use exec-form commands, signal handling in the app, or
--initfor fast, clean shutdowns.
🧪 Practice
- (Beginner) Run
postgres:16with-e POSTGRES_PASSWORD=x, stop it, and read its exit code and last log lines. Explain what the log says about the shutdown.- (Intermediate) Write a short shell script that traps
SIGTERMand prints a message before exiting, run it as the container's command, and compare the stop time and exit code with an untrapped version.- Interview question: "Your containers always take 10 seconds to stop during deployments, which slows rolling updates. What is the likely cause and how do you fix it?" Hint: consider who is PID 1, shell form versus exec form, and what
--initchanges.
Pausing and Unpausing
Why pause instead of stop. Stopping a container ends its processes, and with them all in-memory state: caches, open connections, computation in progress. Sometimes you only want the container to stop using the CPU for a moment, for instance to take a consistent snapshot of its filesystem, or to temporarily free CPU for a more urgent job, and then continue exactly where it left off.
How it works. docker pause uses the cgroup freezer: the kernel stops scheduling every process in the container's cgroup. The processes are not notified and receive no signal; from their point of view, time simply jumps forward when they are unfrozen. Memory stays allocated, files and sockets stay open.
docker pause docker unpause
running -----------------> paused ----------------------> running
(scheduled on CPUs) (frozen in cgroup, (continues from the
memory kept, exact instruction
0% CPU) where it stopped)
Side effects to be aware of
| Aspect | Behavior while paused |
|---|---|
| CPU | Zero usage |
| Memory | Fully retained (not released) |
| Network | Connections stay open, but nothing answers; clients may time out |
docker exec | Refused: the container is paused |
| Timers | Wall-clock time keeps moving; the app sees a "jump" after unpause |
docker commit pauses the container by default while it captures the filesystem, which is one reason the feature exists.
Example
docker run -d --name counter alpine:3.20 sh -c 'i=0; while true; do i=$((i+1)); echo $i; sleep 1; done'
sleep 3; docker logs --tail 1 counter # e.g. 3
docker pause counter
docker ps --filter name=counter --format '{{.Names}}: {{.Status}}'
# counter: Up 5 seconds (Paused)
docker stats --no-stream counter # CPU % 0.00%, memory still in use
docker exec counter echo hi
# Error response from daemon: Container 4b7e... is paused, unpause the container first
sleep 5; docker logs --tail 1 counter # still 3 or 4: no output while frozen
docker unpause counter
sleep 2; docker logs --tail 1 counter # continues counting from where it stopped
docker rm -f counter
Key takeaways
pausefreezes all processes using the cgroup freezer; no signal is sent.- Memory and open connections are retained, but the container does not respond.
unpauseresumes execution exactly where it stopped.- Pausing is not a resource-saving substitute for stopping: memory stays allocated.
🧪 Practice
- (Beginner) Pause an nginx container with a published port and run
curl --max-time 5 localhost:PORT. Describe what happens, then unpause and repeat.- (Intermediate) On a cgroup v2 host, find the container's cgroup directory and read the
cgroup.freezefile before and afterdocker pause.- Interview question: "What is the difference between
docker pauseand sendingSIGSTOPto the container's process?" Hint: think about which processes are affected, whether the process can notice it, and child processes.
Removing Containers
Why removal is a separate step. A stopped container is not gone: its writable layer, configuration, and logs remain on disk so you can inspect it, read its logs, copy files out, or start it again. This is useful for debugging but means stopped containers accumulate and consume disk space until they are explicitly removed.
What removal deletes
Deleted by docker rm | Kept |
|---|---|
| The container's writable layer (all changes made inside it) | The image |
| Its configuration and log files | Named volumes |
| Its name (which becomes free for reuse) | Bind-mounted host directories |
Anonymous volumes, only with -v | Anonymous volumes without -v |
Ways to remove
| Command | Effect |
|---|---|
docker rm NAME | Removes a stopped container; fails for running ones |
docker rm -f NAME | Kills (SIGKILL, no grace period) then removes |
docker rm -v NAME | Also removes its anonymous volumes |
docker run --rm ... | Removes the container and its anonymous volumes automatically when it exits |
docker container prune | Removes all stopped containers (see Pruning Unused Resources) |
Example
docker run -d --name tmp-web nginx:1.27-alpine
docker rm tmp-web
# Error response from daemon: cannot remove container "/tmp-web": container is running:
# stop the container before removing or force remove
docker stop tmp-web && docker rm tmp-web # graceful
# or: docker rm -f tmp-web # immediate, skips graceful shutdown
# Remove all exited containers created from a given image
docker ps -aq --filter status=exited --filter ancestor=alpine:3.20 | xargs -r docker rm
# Anonymous volumes: kept by default, removed with -v
docker run -d --name db -e POSTGRES_PASSWORD=x postgres:16 # image declares VOLUME /var/lib/postgresql/data
docker inspect -f '{{range .Mounts}}{{.Name}}{{end}}' db # 64-hex-character volume name
docker rm -fv db # container AND its anonymous volume
# Throwaway container: nothing is left behind after it exits
docker run --rm alpine:3.20 sh -c 'echo scratch > /tmp/x; cat /tmp/x'
Key takeaways
- Stopped containers keep their writable layer and logs until removed.
docker rm -fskips graceful shutdown; preferstopthenrmfor stateful services.- Named volumes and bind mounts are never deleted by
docker rm; anonymous volumes only with-vor--rm. - Use
--rmfor one-off and interactive containers.
🧪 Practice
- (Beginner) Run five
alpine echocontainers without--rm, list them withdocker ps -a, and remove them all with a single command.- (Intermediate) Run
postgres:16without a named volume, insert a row, remove the container without-v, and usedocker volume lsto find the orphaned volume. Start a new container that reuses it and confirm the data is still there.- Interview question: "Is it safe to run
docker rm -fon a database container?" Hint: compareSIGKILLwith a graceful shutdown, and consider where the data actually lives.
Container States
Why states matter. When something goes wrong, the first question is "what state is the container in, and why?" Docker tracks a small set of states plus detailed metadata (exit code, OOM flag, timestamps, restart count) that usually point straight at the problem.
The state machine
docker create
|
v
+---------+ docker start +---------+ docker pause +--------+
| created | ---------------> | running | ----------------> | paused |
+---------+ +---------+ <---------------- +--------+
| | ^ docker unpause
| process exits / | | restart policy
| stop / kill v | triggers
| +------------+
| | restarting |
| +------------+
| |
| v
| +--------+ docker start
| | exited | --------------> running
| +--------+
| |
+------------+------------+
| docker rm
v
+----------+ +------+
| removing | -------> | dead | (removal failed, e.g. busy mount)
+----------+ +------+
| State | Meaning |
|---|---|
created | Exists but has never been started |
running | Main process is running |
paused | Processes frozen by the cgroup freezer |
restarting | Restart policy is restarting it (watch for loops) |
exited | Main process has ended; writable layer and logs still exist |
removing | Removal in progress |
dead | Removal failed partway; usually needs docker rm -f again or daemon cleanup |
Exit codes. The exit code of an exited container tells you who ended it and how:
| Code | Typical cause |
|---|---|
0 | Normal completion |
1, 2, ... | Application error (meaning defined by the app) |
125 | Docker itself failed (bad flag, daemon error) |
126 | Command found but not executable (permissions, wrong format) |
127 | Command not found in the image |
137 | SIGKILL: docker kill, stop timeout, or out-of-memory kill (check OOMKilled) |
139 | SIGSEGV: segmentation fault, often a binary built for a different libc or architecture |
143 | SIGTERM not handled by the process |
Example
# Short and detailed state
docker ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}'
# NAMES STATE STATUS
# web running Up 2 hours
# job exited Exited (1) 5 minutes ago
docker inspect -f '{{json .State}}' job | jq
# {
# "Status": "exited",
# "Running": false, "Paused": false, "Restarting": false,
# "OOMKilled": false, <- true means the memory limit was hit
# "Dead": false,
# "Pid": 0,
# "ExitCode": 1,
# "Error": "", <- Docker-level error, e.g. failed mount
# "StartedAt": "2026-10-14T09:12:01.53Z",
# "FinishedAt": "2026-10-14T09:12:04.11Z"
# }
# Provoke an OOM kill to see 137 + OOMKilled
docker run --name hog --memory 32m alpine:3.20 sh -c 'head -c 100m /dev/zero | tail'
docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' hog
# exit=137 oom=true
# Command not found
docker run --name typo alpine:3.20 bashh; echo "exit: $?"
# ... exec: "bashh": executable file not found in $PATH ... exit: 127
docker rm hog typo
Key takeaways
- Containers move through created, running, paused, restarting, exited, removing, and dead.
docker inspect.Statecontains the exit code, OOM flag, error message, and timestamps.- 125–127 point at Docker or the command itself; 137 means
SIGKILL, so checkOOMKilled. - A container stuck flipping between
restartingandrunningis in a restart loop; read its logs.
🧪 Practice
- (Beginner) Produce containers that end with exit codes 0, 1, 127, and 137, and display all four with a single
docker ps -a --formatcommand.- (Intermediate) Run a container with
--restart on-failureand the commandsh -c 'sleep 2; exit 1'. Watch its state changes withdocker eventsand readRestartCountfromdocker inspect.- Interview question: "A container shows
Exited (137). How do you tell whether it was killed by the OOM killer or by an operator?" Hint: look at.State.OOMKilled,docker events, and the kernel log (dmesg).
<a id="32-interacting-with-containers"></a>
3.2 Interacting with Containers
This section covers how to get into a container, get data and output out of it, and read its full configuration.
Interactive and Detached Modes
Why there are modes. Containers serve two very different purposes: long-running services (a web server that should run in the background) and interactive sessions (a shell, a REPL, a one-off command whose output you want to see). Docker controls how your terminal is connected to the container's main process with three flags: -i, -t, and -d.
What the flags do
| Flag | Meaning | Without it |
|---|---|---|
-i, --interactive | Keep the container's STDIN open and connected to yours | The process gets no input (immediate EOF) |
-t, --tty | Allocate a pseudo-terminal: prompts, colors, line editing, Ctrl+C as a terminal signal | Plain pipes; programs behave as when output is redirected |
-d, --detach | Run in the background and return immediately | Foreground: your terminal is attached to the output |
Common combinations
| Command | Use case |
|---|---|
docker run -it alpine sh | Interactive shell |
docker run -d nginx | Background service |
docker run -dit alpine sh | Background container with a shell you can attach to later |
docker run --rm -i alpine sort < file.txt | Feed data through STDIN (no -t, because input is a file) |
docker run --rm alpine ls / | Foreground one-shot command, output only |
Detaching without stopping. In a container started with -it, the key sequence Ctrl+P, Ctrl+Q detaches your terminal and leaves the container running. Ctrl+C or exit instead ends the main process, and therefore the container.
TTY side effects. With -t, stdout and stderr are merged into a single terminal stream, and output may contain carriage returns (\r\n) and color codes. In scripts and CI pipelines, omit -t: without a real terminal, Docker fails with the input device is not a TTY.
foreground (-it) detached (-d)
+------------+ stdin/stdout +-----+ +------------+ +-----+
| your shell |<================>| PID1| | your shell | (returns ID) | PID1| --> logs stored by
+------------+ via the CLI +-----+ +------------+ +-----+ the logging driver
Example
# Interactive shell; the container is removed when you exit
docker run --rm -it ubuntu:24.04 bash
# root@3a1f0c2b9d7e:/# cat /etc/os-release | head -n 2
# root@3a1f0c2b9d7e:/# exit
# Without -t: no prompt, but input still flows (-i)
docker run --rm -i alpine:3.20 sh <<'EOF'
echo "running a script from a heredoc"
uname -a
EOF
# Pipe data through a container
printf 'banana\napple\ncherry\n' | docker run --rm -i alpine:3.20 sort
# apple
# banana
# cherry
# Detach from an interactive container without stopping it
docker run -it --name shell alpine:3.20 sh
# / # (press Ctrl+P then Ctrl+Q)
docker ps --filter name=shell --format '{{.Status}}' # Up 12 seconds
docker attach shell # back at the prompt; type exit to end it
# -t in a non-interactive context
echo hi | docker run --rm -it alpine:3.20 cat
# the input device is not a TTY
Key takeaways
-iconnects STDIN,-tallocates a terminal,-druns in the background.- Use
-itfor shells,-dfor services,-ialone for piping data, and no-tin scripts. - Ctrl+P, Ctrl+Q detaches from an
-itcontainer; Ctrl+C orexitusually stops it. - A TTY merges stdout and stderr and adds terminal formatting to the output.
🧪 Practice
- (Beginner) Run
docker run --rm alpine sh,docker run --rm -i alpine sh, anddocker run --rm -it alpine sh. Describe the behavior of each and explain it with the table above.- (Beginner) Start a Python REPL in a container (
python:3.13-slim), detach with Ctrl+P, Ctrl+Q, reattach, and confirm a variable you defined is still there.- (Intermediate) Run
docker run --rm -t alpine sh -c 'echo out; echo err >&2' 2>/dev/nullwith and without-t. Explain why stderr is not filtered out in one of the two cases.
docker exec
Why exec exists. Once a service is running, you often need to look inside: check a configuration file, query a database with its own client, test connectivity, or inspect running processes. Stopping the container to do so would interrupt the service. docker exec starts an additional process inside the already-running container, sharing all its namespaces, cgroups, and filesystem.
How it relates to the main process
container "web" (same namespaces, same cgroup, same filesystem)
+-----------------------------------------------------------+
| PID 1 nginx: master process <- main process |
| PID 30 nginx: worker process |
| PID 41 sh <- docker exec -it |
| PID 47 ps aux <- run from that sh |
+-----------------------------------------------------------+
If PID 1 exits, the container stops and every exec'd process is killed with it.
Rules to remember
- The container must be running (not created, exited, or paused).
- The exec'd process is not PID 1 and does not affect the container's lifecycle when it exits.
- It inherits the container's environment and default user and working directory, which you can override with
-e,-u, and-w. - It is not remembered: after a container restart, exec'd processes are gone.
- The program must exist in the image. Minimal images (distroless,
scratch) often have no shell; see docker debug for alternatives.
Example
docker run -d --name web nginx:1.27-alpine
# Interactive shell inside the running container
docker exec -it web sh
# / # ps
# PID USER TIME COMMAND
# 1 root 0:00 nginx: master process nginx -g daemon off;
# 30 nginx 0:00 nginx: worker process
# 41 root 0:00 sh
# 47 root 0:00 ps
# / # exit <- ends only the shell; nginx keeps running
# One-off, non-interactive commands
docker exec web nginx -t # validate the configuration
docker exec web cat /etc/nginx/conf.d/default.conf
docker exec web nginx -s reload # apply configuration changes
# Override user, working directory, and environment for this process only
docker exec -u nginx -w /tmp -e DEBUG=1 web sh -c 'id; pwd; echo $DEBUG'
# uid=101(nginx) gid=101(nginx) groups=101(nginx)
# /tmp
# 1
# Use the database client shipped in the image
docker run -d --name db -e POSTGRES_PASSWORD=secret postgres:16
docker exec -it db psql -U postgres -c 'select version();'
# Run in the background inside the container (-d)
docker exec -d web sh -c 'sleep 300'
# The program must exist in the image
docker exec -it web bash
# OCI runtime exec failed: exec failed: unable to start container process:
# exec: "bash": executable file not found in $PATH <- alpine has sh, not bash
Key takeaways
docker execruns an extra process in a running container, sharing its namespaces and filesystem.- Exiting an exec'd process never stops the container; the main process controls the lifecycle.
- Use
-itfor shells, plainexecfor one-off commands, and-u,-w,-eto adjust the context. - Only programs present in the image can be executed; minimal images may have no shell at all.
🧪 Practice
- (Beginner) Run a
redis:7container and usedocker execwithredis-clito set and read a key, without ever opening a shell.- (Intermediate) Exec into a running nginx container, change the default page, then restart the container with
docker restart. Is your change still there? What about afterdocker rmand a newdocker run? Explain both results.- Interview question: "How would you debug a running container whose image contains no shell and no debugging tools?" Hint: consider sharing namespaces from another container (
--pid container:NAME,--network container:NAME),nsenteron the host, anddocker debug.
docker attach
Why attach exists. docker exec starts a new process. Sometimes you want to connect to the main process itself: to see its live output, or to type into an interactive program you started earlier with -dit (a shell, a REPL, a game server console). docker attach connects your terminal to the main process's STDIN, STDOUT, and STDERR.
attach versus exec
docker attach | docker exec | |
|---|---|---|
| Target | The existing main process (PID 1) | A new process |
| Input | Goes to PID 1's STDIN | Goes to the new process |
| Ctrl+C | Sent to PID 1, usually stopping the container | Ends only the new process |
| Multiple users | Everyone attached shares the same session | Each exec is independent |
| Typical use | Consoles of interactive programs, watching output | Debugging, admin commands |
Detaching safely. If the container was started with -it, use Ctrl+P, Ctrl+Q to detach. You can change the sequence with --detach-keys. If it was started without -t, the detach sequence does not work and Ctrl+C will forward SIGINT to the process; use --sig-proxy=false so Ctrl+C only detaches your client. For simply watching output, docker logs -f is safer.
Example
# A container whose main process is an interactive shell
docker run -dit --name console alpine:3.20 sh
docker attach console
# / # echo "typed into PID 1"
# typed into PID 1
# (Ctrl+P, Ctrl+Q) -> detached, container still running
docker ps --filter name=console --format '{{.Status}}' # Up ...
# Attach to a service just to watch output; Ctrl+C detaches without stopping it
docker run -d --name web -p 8080:80 nginx:1.27-alpine
docker attach --sig-proxy=false web
# (in another terminal: curl localhost:8080)
# 172.17.0.1 - - [14/Oct/2026:10:15:02 +0000] "GET / HTTP/1.1" 200 615 ...
# ^C <- only the client detaches
# Custom detach keys (Ctrl+X then x)
docker attach --detach-keys='ctrl-x,x' console
# Attach without sending input (read-only view)
docker attach --no-stdin console
Key takeaways
docker attachconnects your terminal to the container's main process; it starts nothing new.- Ctrl+C while attached can stop the container; Ctrl+P, Ctrl+Q detaches if a TTY was allocated.
- Use
--sig-proxy=falseordocker logs -fwhen you only want to watch output. - Prefer
docker execfor debugging and administration.
🧪 Practice
- (Beginner) Start
python:3.13-slimwith-ditand the commandpython. Attach, define a variable, detach, attach again, and print the variable.- (Intermediate) Open two terminals and attach both to the same
-dit alpine shcontainer. Type in one and observe the other. Explain the result.- Interview question: "A colleague attached to a production container to watch logs and pressed Ctrl+C, which took the service down. Why, and what should they have used?" Hint: think about where the signal goes and the difference between attaching and streaming logs.
Copying Files with docker cp
Why copy instead of mount. Volumes and bind mounts (chapter 6) are the right tool for ongoing data sharing, but they must be set up when the container is created. For ad-hoc transfers, such as grabbing a crash dump, a generated report, or a log file, or pushing in a quick configuration fix for testing, docker cp copies files between the host and any container, even a stopped one.
Syntax
docker cp [OPTIONS] CONTAINER:SRC_PATH DEST_PATH container -> host
docker cp [OPTIONS] SRC_PATH CONTAINER:DEST_PATH host -> container
docker cp [OPTIONS] CONTAINER:SRC_PATH - container -> tar stream on stdout
docker cp [OPTIONS] - CONTAINER:DEST_PATH tar stream on stdin -> container
Path semantics (they mirror cp -R):
| Source | Destination | Result |
|---|---|---|
| File | Existing directory | File copied into the directory |
| File | Non-existent path | File saved under that name |
dir | Non-existent path | Directory created with that name and contents |
dir | Existing directory | dir copied inside it (dest/dir/...) |
dir/. | Existing directory | Only the contents of dir copied into it |
Details that surprise people
- Files copied into a container are owned by root (UID 0) by default;
-a(archive) preserves the original UID/GID. - Copies only touch the container's filesystem view; files under
/proc,/sys,/dev, and tmpfs mounts cannot be copied this way. For those, runtarthroughdocker execinstead. - Files placed into a running container do not trigger anything: the application must reread them (for example
nginx -s reload). - Changes are made in the writable layer and are lost when the container is removed.
Example
docker run -d --name web nginx:1.27-alpine
# Container -> host
docker cp web:/etc/nginx/nginx.conf ./nginx.conf
# Host -> container, then reload
echo 'server_tokens off;' > hardening.conf
docker cp hardening.conf web:/etc/nginx/conf.d/hardening.conf
docker exec web nginx -s reload
# Directory contents only (note the trailing /.)
mkdir -p site && echo '<h1>new</h1>' > site/index.html
docker cp site/. web:/usr/share/nginx/html/
# Works on stopped containers: retrieve output after a job has finished
docker run --name report alpine:3.20 sh -c 'date > /tmp/report.txt'
docker cp report:/tmp/report.txt .
docker rm report
# Stream as tar: archive a directory straight from the container
docker cp web:/var/cache/nginx - | gzip > nginx-cache.tar.gz
# Preserve ownership when copying in
docker cp -a ./site web:/srv/site
Key takeaways
docker cpcopies in either direction and works on running and stopped containers.- A trailing
/.copies a directory's contents rather than the directory itself. - Copied-in files are owned by root unless you use
-a. - For permanent or continuous data exchange use volumes or bind mounts instead.
🧪 Practice
- (Beginner) Copy
/etc/os-releaseout ofubuntu:24.04,debian:12, andalpine:3.20containers without starting any of them (hint:docker create).- (Intermediate) Copy a directory into a container twice, once as
dirand once asdir/., into an existing destination directory. Usedocker exec ... ls -Rto show the difference.- (Intermediate) Copy a file owned by your user into a container with and without
-a, and compare the ownership shown byls -lninside the container.
Viewing Logs
Why logs go to stdout. Inside a container there is usually no syslog, no log rotation daemon, and no one logged in to read files under /var/log. Docker's convention is that the main process writes logs to stdout and stderr. Docker captures both streams through a logging driver, stores them (by default as JSON files on the host), and serves them via docker logs. This keeps logging uniform across all containers and lets you redirect logs elsewhere without changing the application.
app (PID 1) --stdout--> +---------------+ json-file / local --> docker logs
--stderr--> | dockerd | --> journald --> docker logs
| logging driver| syslog, fluentd, --> external system
+---------------+ awslogs, gelf, ... (docker logs via dual-logging cache)
Only the main process's output is captured. A process started with docker exec, or an app that writes to files inside the container, does not appear in docker logs. Official images work around file-based apps by symlinking log files to the standard streams (nginx links /var/log/nginx/access.log to /dev/stdout).
Useful options
| Option | Effect |
|---|---|
-f, --follow | Stream new output continuously |
--tail N | Show only the last N lines (all by default) |
--since, --until | Time window: relative (10m, 2h) or timestamp (2026-10-14T09:00:00) |
-t, --timestamps | Prefix each line with Docker's receive timestamp |
--details | Show extra attributes added by the logging driver (--log-opt labels=...) |
Retention. Logs are kept until the container is removed. The default json-file driver does not rotate logs, so a chatty container can fill the disk. Configure max-size/max-file per container or globally in daemon.json; logging drivers and rotation are covered in 12.1 Logging.
Example
docker run -d --name web -p 8080:80 nginx:1.27-alpine
curl -s localhost:8080 >/dev/null; curl -s localhost:8080/missing >/dev/null
docker logs web # everything since the container was created
docker logs --tail 2 -t web # last two lines with timestamps
# 2026-10-14T10:20:11.402Z 172.17.0.1 - - [...] "GET / HTTP/1.1" 200 615 ...
# 2026-10-14T10:20:11.987Z 172.17.0.1 - - [...] "GET /missing HTTP/1.1" 404 153 ...
docker logs -f --since 5m web # follow, starting 5 minutes back (Ctrl+C stops following only)
# stdout and stderr are kept separate: redirect both before piping
docker logs web 2>&1 | grep -c ' 404 '
# Per-container rotation: at most 3 files of 10 MB each
docker run -d --name chatty --log-opt max-size=10m --log-opt max-file=3 \
alpine:3.20 sh -c 'while true; do date; sleep 0.1; done'
# Where the log file lives (json-file driver)
docker inspect -f '{{.LogPath}}' chatty
# /var/lib/docker/containers/<id>/<id>-json.log
# Logs of a crashed container remain readable until it is removed
docker run --name crash alpine:3.20 sh -c 'echo starting; echo "fatal: no config" >&2; exit 1'
docker logs crash
# starting
# fatal: no config
Key takeaways
- Containers should log to stdout/stderr; Docker captures only the main process's streams.
docker logssupports following, tailing, time windows, and timestamps.- stderr is returned on stderr, so use
2>&1before piping togrep. - The default driver does not rotate logs; always configure
max-sizeandmax-file. - Logs survive stop but are deleted with the container.
🧪 Practice
- (Beginner) Run a container that prints a line every second, and use
docker logsto show only lines from the last 10 seconds, then only the last 5 lines with timestamps.- (Intermediate) Run a container that writes its log to
/app/app.loginstead of stdout and observe thatdocker logsis empty. Fix it without changing the application (hint: a symlink, ortail -Fas a second process).- Interview question: "A host's disk is full and
/var/lib/docker/containersis the largest directory. What happened and how do you prevent it?" Hint: consider the default logging driver's behavior and where rotation is configured.
Inspecting Containers
Why inspect. docker ps shows a one-line summary. When you need the real answer to "which IP does this container have?", "what command did it actually run?", "which volume is mounted where?", or "why did it exit?", docker inspect returns the container's complete low-level configuration and state as JSON.
Structure of the output. The most useful top-level sections:
| Section | Contains |
|---|---|
State | Status, PID, exit code, OOMKilled, start/finish times, health status |
Config | What runs: image, Cmd, Entrypoint, Env, User, WorkingDir, Labels, exposed ports (image defaults merged with your flags) |
HostConfig | How it runs: port bindings, restart policy, resource limits, capabilities, log config |
NetworkSettings | Networks, IP addresses, gateways, MAC addresses, published ports |
Mounts | Volumes, bind mounts, and tmpfs mounts with source and destination |
| Top level | Id, Name, Created, Image (ID), LogPath, RestartCount, Platform |
Formatting with Go templates. The -f/--format flag extracts fields using Go template syntax, so you do not need extra tools for simple queries. For complex queries, pipe the JSON to jq.
| Template | Purpose |
|---|---|
{{.State.Status}} | Access a field |
{{json .Mounts}} | Print a sub-object as JSON |
{{range .Config.Env}}{{println .}}{{end}} | Iterate over a list |
{{index .Config.Labels "com.example.team"}} | Look up a map key with special characters |
{{range $k, $v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}} | Iterate over a map |
{{join .Config.Cmd " "}} | Join a list into a string |
Example
docker network create appnet
docker run -d --name web --network appnet -p 8080:80 \
-e MODE=prod --label com.example.team=platform \
-v webdata:/data nginx:1.27-alpine
docker inspect web | less # the full JSON document (an array with one element)
# Targeted queries
docker inspect -f '{{.State.Status}} since {{.State.StartedAt}}' web
docker inspect -f '{{range $net, $cfg := .NetworkSettings.Networks}}{{$net}}: {{$cfg.IPAddress}}{{"\n"}}{{end}}' web
# appnet: 172.19.0.2
docker inspect -f '{{json .HostConfig.PortBindings}}' web
# {"80/tcp":[{"HostIp":"","HostPort":"8080"}]}
docker inspect -f '{{range .Mounts}}{{.Type}} {{.Name}} -> {{.Destination}}{{"\n"}}{{end}}' web
# volume webdata -> /data
docker inspect -f '{{index .Config.Labels "com.example.team"}}' web
# platform
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' web
# Prints MODE=prod together with the variables inherited from the image
# (PATH=..., NGINX_VERSION=..., ...)
# Complex queries with jq
docker inspect web | jq '.[0].HostConfig | {RestartPolicy, Memory, NanoCpus}'
# Inspect several containers at once
docker inspect -f '{{.Name}} {{.State.Status}}' $(docker ps -aq)
# Disambiguate when an image and a container share a name
docker inspect --type container web
docker inspect also works on images, volumes, networks, and other objects; docker container inspect restricts it to containers.
Key takeaways
docker inspectreturns the complete configuration and state of a container as JSON.Configdescribes what runs,HostConfighow it runs,Statewhat happened.- Use
-fwith Go templates for quick answers andjqfor complex filtering. - Per-network IP addresses are under
.NetworkSettings.Networks, not at the top level.
🧪 Practice
- (Beginner) Write a
docker inspect -fcommand that prints the image, restart policy, and exit code of a container on one line.- (Intermediate) Write a one-liner that lists every container (running or not) with its name and all of its mount destinations.
- Interview question: "An environment variable you set with
-edoes not seem to take effect in the application. How do you verify what the container actually received?" Hint: compare.Config.Envindocker inspectwith what the running process sees in/proc/1/environ, and think about entrypoint scripts that may overwrite values.
<a id="33-container-configuration"></a>
3.3 Container Configuration
This section covers the options that shape how a single container runs: its environment, network exposure, identity, resilience, user, and startup command.
Environment Variables
Why environment variables. The same image should run unchanged in development, staging, and production; only its configuration differs (database URL, log level, feature flags). Baking those values into the image would require a separate build per environment. The widely adopted convention (from the Twelve-Factor App methodology) is to pass configuration through environment variables when the container starts.
Where a container's environment comes from
image ENV (Dockerfile) PATH=..., NGINX_VERSION=1.27.x
| overridden by
v
--env-file FILE values read from the file, in order
| overridden by
v
-e KEY=value / -e KEY command-line values (later flags win)
|
v
Config.Env --> environment of the main process (and inherited by docker exec)
Ways to set variables
| Syntax | Effect |
|---|---|
-e KEY=value | Set an explicit value |
-e KEY | Copy KEY from the host shell's environment (omitted if unset on the host) |
--env-file FILE | Read KEY=value lines from a file |
--env-file format rules. One KEY=value per line; lines starting with # are comments; a line with only KEY copies the host value. Quotes are not removed and no variable expansion happens: NAME="web" sets the value to "web" including the quotes. (Compose .env files follow different rules; see Environment and env_file.)
Limits of environment variables
- They are fixed at creation time; changing one requires recreating the container.
- They are visible to anyone who can run
docker inspectand to every process in the container (/proc/*/environ), and they often leak into logs and crash reports. Do not use them for highly sensitive secrets; see 10.4 Secrets Management.
Example
# Explicit values
docker run --rm -e APP_ENV=staging -e LOG_LEVEL=debug alpine:3.20 sh -c 'echo $APP_ENV $LOG_LEVEL'
# staging debug
# Pass through a host variable without writing its value on the command line
export API_TOKEN=abc123
docker run --rm -e API_TOKEN alpine:3.20 printenv API_TOKEN
# abc123
# Env file
cat > app.env <<'EOF'
# database settings
DB_HOST=db.internal
DB_PORT=5432
QUOTED="kept literally"
API_TOKEN
EOF
docker run --rm --env-file app.env alpine:3.20 sh -c 'echo "$DB_HOST:$DB_PORT [$QUOTED] $API_TOKEN"'
# db.internal:5432 ["kept literally"] abc123
# -e overrides the env file and the image
docker run --rm --env-file app.env -e DB_PORT=6543 alpine:3.20 printenv DB_PORT
# 6543
# Typical real use: configuring an official image
docker run -d --name db \
-e POSTGRES_USER=app \
-e POSTGRES_PASSWORD=change-me \
-e POSTGRES_DB=appdb \
postgres:16
# Everything set on the container is visible to anyone with Docker access
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' db | grep PASSWORD
# POSTGRES_PASSWORD=change-me
Key takeaways
- Environment variables let one image serve many environments.
- Precedence: image
ENV<--env-file<-e. --env-filetakes values literally: no quote stripping, no expansion.- Environment variables are readable through
docker inspect; prefer secrets mechanisms for credentials.
🧪 Practice
- (Beginner) Run
mysql:8with the minimum environment variables required by its documentation and connect withdocker exec ... mysql.- (Intermediate) Create an env file containing a quoted value, a comment, and a pass-through variable. Predict the output of
printenvin the container, then verify.- Interview question: "Why are environment variables considered a weak place for secrets, and what would you use instead?" Hint: list everywhere an environment variable is visible (inspect output, child processes,
/proc, crash dumps) and compare with file-based secrets.
Port Publishing
Why publish ports. Each container has its own network namespace with its own IP address on a private Docker network. Services listening inside it are not reachable from outside the host (and on Docker Desktop, not even from the host itself). Publishing a port creates a mapping from a port on the host to a port in the container, so external clients can reach the service.
client --> host 203.0.113.10:8080 --+
| NAT rule (iptables/nftables) or docker-proxy
v
container 172.17.0.2:80 (nginx listening on 0.0.0.0:80)
Syntax variants
| Option | Meaning |
|---|---|
-p 8080:80 | Host port 8080 on all interfaces to container port 80 (TCP) |
-p 127.0.0.1:8080:80 | Only reachable from the host itself |
-p 80 | Container port 80 to a random free host port |
-p 5353:53/udp | UDP instead of TCP |
-p 8000-8005:8000-8005 | A range of ports |
-P | Publish every port declared with EXPOSE in the image, each to a random host port |
EXPOSE is documentation. EXPOSE 80 in a Dockerfile only records metadata (used by -P and shown by docker ps); it publishes nothing. Conversely, containers on the same user-defined network can reach each other on any port without publishing (see 7. Docker Networking).
Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| "Connection reset" although the port is published | The app listens on 127.0.0.1 inside the container | Make it listen on 0.0.0.0 |
port is already allocated / address already in use | Another container or host process uses that host port | Choose another host port |
| Service reachable from the internet despite a host firewall (ufw/firewalld) | Docker inserts its own NAT and forwarding rules ahead of many firewall frontends' rules | Bind to 127.0.0.1 or configure the DOCKER-USER chain |
Example
docker run -d --name web -p 8080:80 nginx:1.27-alpine
curl -sI localhost:8080 | head -n 1 # HTTP/1.1 200 OK
# Local-only binding: not reachable from other machines
docker run -d --name admin -p 127.0.0.1:9000:80 nginx:1.27-alpine
# Random host port, then discover it
docker run -d --name rnd -p 80 nginx:1.27-alpine
docker port rnd
# 80/tcp -> 0.0.0.0:32768
# 80/tcp -> [::]:32768
curl -sI "localhost:$(docker port rnd 80/tcp | head -n 1 | cut -d: -f2)" | head -n 1
# Publish everything the image EXPOSEs
docker run -d --name all -P nginx:1.27-alpine
docker ps --filter name=all --format '{{.Ports}}'
# 0.0.0.0:32769->80/tcp, [::]:32769->80/tcp
# Port conflict
docker run -d --name web2 -p 8080:80 nginx:1.27-alpine
# docker: Error response from daemon: ... Bind for 0.0.0.0:8080 failed: port is already allocated
# The classic loopback mistake: a server bound to 127.0.0.1 inside the container
docker run -d --name loop -p 8000:8000 python:3.13-slim python -m http.server 8000 --bind 127.0.0.1
curl localhost:8000 # curl: (56) Recv failure: Connection reset by peer
# (or "Empty reply from server", depending on the setup)
docker rm -f loop
docker run -d --name loop -p 8000:8000 python:3.13-slim python -m http.server 8000 --bind 0.0.0.0
curl -sI localhost:8000 | head -n 1 # HTTP/1.0 200 OK
Key takeaways
-p HOST:CONTAINERmaps a host port to a container port; the default binds to all host interfaces.- Prefix with
127.0.0.1:for services that must not be exposed to the network. EXPOSEdocuments ports but publishes nothing;-Ppublishes exposed ports to random host ports.- The application must listen on
0.0.0.0inside the container to be reachable.
🧪 Practice
- (Beginner) Run two nginx containers published on host ports 8081 and 8082, and show different content in each using
docker cp.- (Intermediate) Publish a port bound to
127.0.0.1and another bound to all interfaces. From a second machine (or another container using the host's IP), show which one is reachable.- Interview question: "You enabled ufw and denied port 5432, but your containerized PostgreSQL published with
-p 5432:5432is still reachable from the internet. Why?" Hint: think about the order in which Docker's iptables rules and ufw's rules are evaluated, and where published traffic is redirected.
Naming Containers
Why names matter. Every container gets a 64-character hexadecimal ID, which is unique but impossible to remember. Names give containers a stable, human-readable identity that you can use in commands, scripts, and, on user-defined networks, as DNS hostnames for container-to-container communication.
How names work
| Aspect | Behavior |
|---|---|
| Default | Random adjective_surname name (quirky_hopper, elated_turing) |
| Custom | --name web; allowed characters [a-zA-Z0-9][a-zA-Z0-9_.-]* |
| Uniqueness | Unique per Docker daemon, including stopped containers |
| Renaming | docker rename OLD NEW, also while running |
| ID prefixes | Any unique prefix of the ID works (docker stop 9f2c) |
| DNS | On user-defined networks, the name resolves to the container's IP |
| Hostname | Separate from the name; defaults to the short ID; set with --hostname |
Name conflicts. Because stopped containers keep their names, re-running docker run --name web ... fails until the old container is removed. Scripts usually remove the old container first, or use --rm so the name is freed automatically. Compose generates names automatically (project-service-1), which is one reason it is preferred for multi-container setups.
Example
docker run -d nginx:1.27-alpine
docker ps --format '{{.ID}} {{.Names}}'
# 6c1d8e0f2a3b festive_lovelace <- generated name
docker run -d --name web nginx:1.27-alpine
docker run -d --name web nginx:1.27-alpine
# docker: Error response from daemon: Conflict. The container name "/web" is already in use
# by container "9f2c5e1a...". You have to remove (or rename) that container to be able to reuse that name.
# Idempotent "replace" pattern used in scripts
docker rm -f web 2>/dev/null || true
docker run -d --name web nginx:1.27-alpine
docker rename web web-old
# Names as DNS names on a user-defined network
docker network create shop
docker run -d --name api --network shop nginx:1.27-alpine
docker run --rm --network shop alpine:3.20 wget -qO- http://api | head -n 4
# Name versus hostname
docker run --rm --name n1 alpine:3.20 hostname # short container ID
docker run --rm --name n1 --hostname builder alpine:3.20 hostname # builder
Key takeaways
- Names are unique per daemon, including stopped containers; remove or rename the old one to reuse a name.
- Names double as DNS hostnames on user-defined networks, but not on the default bridge.
- Hostname and container name are independent settings.
- Any unique ID prefix can be used instead of a name.
🧪 Practice
- (Beginner) Start three containers without names, rename them to
one,two,three, and stop them by name.- (Intermediate) Show that
ping apiworks from another container on a user-defined network but fails when both containers are on the default bridge network.- (Intermediate) Write a shell function
redeploy NAME IMAGEthat replaces a running container with a fresh one under the same name, and explain what happens ifdocker runfails halfway.
Restart Policies
Why restart policies. Processes crash, hosts reboot, and the Docker daemon gets upgraded. Without a policy, a container that stops stays stopped until someone notices. A restart policy tells the daemon to bring the container back automatically, which gives single-host deployments basic self-healing without a separate supervisor.
The four policies
| Policy | Restart when the process exits? | After docker stop | When the daemon (or host) restarts |
|---|---|---|---|
no (default) | Never | Stays stopped | Stays stopped |
on-failure[:N] | Only on non-zero exit code, at most N times | Stays stopped | Not restarted |
always | Always, whatever the exit code | Stays stopped until the daemon restarts | Started |
unless-stopped | Always, whatever the exit code | Stays stopped, permanently | Started, unless it was stopped manually |
Backoff. To avoid hammering a crashing app, the delay before each restart doubles, starting at 100 ms and capped at one minute. The delay resets after the container has run for at least 10 seconds. A container in a crash loop shows Restarting (1) X seconds ago in docker ps, and RestartCount grows in docker inspect.
What policies do not do
- They do not react to failed health checks: a standalone container that is
unhealthybut still running is not restarted (Swarm and Kubernetes do that). - They cannot be combined with
--rm. - They restart the same container; they never recreate it from a newer image.
Choosing a policy
| Workload | Suggested policy |
|---|---|
| Long-running service on a single host | unless-stopped |
| Service that must always come back, even after manual stops | always |
| Batch job that should retry on failure, but not forever | on-failure:5 |
| Development, tests, one-off commands | no (often with --rm) |
Example
# A service that should survive crashes and reboots
docker run -d --name web --restart unless-stopped nginx:1.27-alpine
# A job that fails: retried up to 3 times, then left exited
docker run -d --name flaky --restart on-failure:3 alpine:3.20 sh -c 'echo try; exit 1'
sleep 5
docker inspect -f 'status={{.State.Status}} restarts={{.RestartCount}}' flaky
# status=exited restarts=3
docker logs flaky
# try
# try
# try
# try <- initial run + 3 restarts
# A crash loop as seen by docker ps
docker run -d --name loop --restart always alpine:3.20 sh -c 'exit 1'
docker ps --filter name=loop --format '{{.Status}}'
# Restarting (1) 2 seconds ago
# Change the policy of an existing container
docker update --restart no loop && docker stop loop
# Read the policy
docker inspect -f '{{.HostConfig.RestartPolicy.Name}}:{{.HostConfig.RestartPolicy.MaximumRetryCount}}' flaky
# on-failure:3
Key takeaways
- Policies:
no,on-failure[:N],always,unless-stopped. unless-stoppedrespects manual stops across daemon restarts;alwaysdoes not.- Restarts use exponential backoff (100 ms doubling, max 1 minute).
- Restart policies react to process exit, not to health checks.
🧪 Practice
- (Beginner) Run one container with
alwaysand one withunless-stopped, stop both manually, restart the Docker daemon, and explain which one comes back.- (Intermediate) Run a container that exits with code 0 after 2 seconds, once with
on-failureand once withalways. Predict and verify theRestartCountof each after 30 seconds.- Interview question: "Your container is running but its health check reports
unhealthy. Why doesn't the restart policy restart it, and what are your options?" Hint: consider what event the restart policy listens for, and what orchestrators or a small watchdog usingdocker eventscould do.
Working Directory and User
Why override them. Images set a default working directory (WORKDIR) and user (USER), but you often need different values at run time: running a build tool in a mounted project folder, running a command as a specific user so files it creates on a bind mount belong to you, or running as non-root for security even though the image defaults to root.
Working directory: -w / --workdir. Sets the directory the main process starts in. Relative paths in the command are resolved from there. If the directory does not exist, Docker creates it.
User: -u / --user.
| Value | Meaning |
|---|---|
-u appuser | Name looked up in the container's /etc/passwd; fails if it does not exist |
-u 1000 | Numeric UID; works even if no such user exists in the image |
-u 1000:1000 | UID and GID |
-u "$(id -u):$(id -g)" | Your own host UID/GID, common with bind mounts |
--group-add GROUP | Add supplementary groups |
Why the UID matters. Without user namespace remapping, UID 0 inside the container is UID 0 on the host kernel. Its power is reduced by capabilities, seccomp, and namespaces, but a container escape as root is far more dangerous than as an unprivileged user. Files written to bind mounts are owned by the container's UID as seen by the host. Running as a numeric non-root UID addresses both concerns; see Running as Non-Root.
host container (-u 1000:1000)
/home/alice/project (owner 1000:1000) <--> /src process runs as uid 1000
files created here are owned by alice no "root-owned files" left behind
Example
# Default user and directory of an image
docker run --rm node:22-alpine sh -c 'id; pwd'
# uid=0(root) gid=0(root) ...
# /
# Run as the image's existing non-root user, in a chosen directory
docker run --rm -u node -w /home/node node:22-alpine sh -c 'id; pwd'
# uid=1000(node) gid=1000(node) ...
# /home/node
# A name that does not exist in the image fails
docker run --rm -u alice alpine:3.20 id
# docker: Error response from daemon: unable to find user alice: no matching entries in passwd file
# A numeric UID without a passwd entry works
docker run --rm -u 4242:4242 alpine:3.20 sh -c 'id; echo HOME=$HOME'
# uid=4242 gid=4242 groups=4242
# HOME=/
# Run a tool on your project as yourself, so outputs are owned by you
docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/src -w /src alpine:3.20 touch built.txt
ls -l built.txt # owned by your user, not by root
# Same command without -u: the file belongs to root on the host
docker run --rm -v "$PWD":/src -w /src alpine:3.20 touch root-owned.txt
ls -l root-owned.txt # -rw-r--r-- 1 root root ...
# The working directory is created if missing
docker run --rm -w /does/not/exist alpine:3.20 pwd # /does/not/exist
Key takeaways
-wsets (and creates if needed) the starting directory;-usets the user and group.- User names must exist in the image; numeric UIDs always work.
- Root in a container is root on the host kernel unless user namespaces are used.
- Use
-u "$(id -u):$(id -g)"with bind mounts to avoid root-owned files on the host.
🧪 Practice
- (Beginner) Check the default user of
nginx,postgres,node, andpythonimages withdocker run --rm IMAGE id(ordocker image inspect -f '{{.Config.User}}'). Which ones default to root?- (Intermediate) Run
npm init -yin a bind-mounted directory with and without-u "$(id -u):$(id -g)", and compare the ownership of the generatedpackage.json. Explain any errors you see when running as your UID (hint:HOME).- Interview question: "If processes in a container run as root, does that mean they have root on the host?" Hint: distinguish UID mapping from capabilities, seccomp, and namespaces, and consider what changes with user namespace remapping or rootless Docker.
Entrypoint and Command Overrides
Why there are two settings. An image has to define what runs when a container starts, but users should still be able to adjust it. Docker splits the startup command into two parts: ENTRYPOINT, the fixed executable, and CMD, the default arguments (or the default command when there is no entrypoint). This allows images that behave like a tool (docker run mytool --help) as well as images that run a default service but accept a different command.
How the final command is built
final process = ENTRYPOINT + CMD
image: ENTRYPOINT ["docker-entrypoint.sh"] CMD ["postgres"]
run: docker run postgres:16 --> docker-entrypoint.sh postgres
run: docker run postgres:16 postgres -c log_statement=all
--> docker-entrypoint.sh postgres -c log_statement=all
run: docker run --entrypoint bash postgres:16 -c 'id'
--> bash -c id (image CMD is discarded)
Override rules
| You provide | ENTRYPOINT used | CMD used |
|---|---|---|
| Nothing | Image's | Image's |
| Arguments after the image name | Image's | Your arguments |
--entrypoint X | X | Empty (image CMD is cleared) unless you add arguments |
--entrypoint X + arguments | X | Your arguments |
--entrypoint "" | None | Your arguments become the full command |
Rules to remember
--entrypointtakes a single executable; its arguments go after the image name.- Many official images use an entrypoint script that performs setup (create a database, fix permissions) and then runs
exec "$@"with the CMD. Overriding the entrypoint skips that setup. - How shell form versus exec form affects signal handling is covered in Shell vs Exec Form.
Example
# What does an image run by default?
docker image inspect -f 'EP={{json .Config.Entrypoint}} CMD={{json .Config.Cmd}}' nginx:1.27-alpine
# EP=["/docker-entrypoint.sh"] CMD=["nginx","-g","daemon off;"]
docker image inspect -f 'EP={{json .Config.Entrypoint}} CMD={{json .Config.Cmd}}' alpine:3.20
# EP=null CMD=["/bin/sh"]
# Replace CMD: arguments after the image name
docker run --rm nginx:1.27-alpine nginx -v
# /docker-entrypoint.sh: ... (entrypoint setup messages)
# nginx version: nginx/1.27.x
# Replace ENTRYPOINT to get a shell, a common debugging trick
docker run --rm -it --entrypoint sh nginx:1.27-alpine
# / # <- no nginx started, no entrypoint script ran
# --entrypoint with arguments: the arguments follow the image
docker run --rm --entrypoint ls nginx:1.27-alpine -la /etc/nginx
# A tool-style image: ENTRYPOINT is the tool, CMD is the default argument
cat > Dockerfile <<'EOF'
FROM alpine:3.20
ENTRYPOINT ["ping", "-c", "3"]
CMD ["localhost"]
EOF
docker build -q -t pinger .
docker run --rm pinger # ping -c 3 localhost
docker run --rm pinger 1.1.1.1 # ping -c 3 1.1.1.1 (CMD replaced)
docker run --rm --entrypoint echo pinger hi # echo hi (both replaced)
Key takeaways
- The container runs
ENTRYPOINT + CMD. - Arguments after the image name replace
CMD;--entrypointreplacesENTRYPOINTand clearsCMD. docker run --rm -it --entrypoint sh IMAGEis the standard way to explore an image.- Overriding an entrypoint script skips the initialization it would normally perform.
🧪 Practice
- (Beginner) Inspect the entrypoint and command of
redis:7,python:3.13-slim, andpostgres:16, and predict what runs fordocker run IMAGEanddocker run IMAGE bash.- (Intermediate) Run
postgres:16with--entrypoint postgres(plus required arguments) and compare its startup with the default. Explain what the skipped entrypoint script normally does.- Interview question: "When would you put the executable in
ENTRYPOINTinstead ofCMD, and what does that change for users of the image?" Hint: think about tool-style images, how arguments are appended, and how hard it is to override each one.
<a id="34-monitoring-and-housekeeping"></a>
3.4 Monitoring and Housekeeping
This section covers the built-in tools for seeing what is running and what it consumes, and for reclaiming the disk space Docker accumulates over time.
docker ps and Filtering
Why filtering matters. On a developer laptop or a busy server, dozens or hundreds of containers (many of them stopped) can exist at once. docker ps lists them, and its filtering and formatting options turn it into a query tool that scripts can rely on, for example "IDs of all exited containers from this image" or "every unhealthy container".
Core options
| Option | Effect |
|---|---|
| (none) | Running containers only |
-a, --all | Include stopped, created, and dead containers |
-q, --quiet | Print only IDs (ideal for piping into other commands) |
-l, --latest / -n N | The most recently created container / the last N |
-s, --size | Add writable-layer size (and virtual size including the image) |
--no-trunc | Do not shorten IDs and commands |
-f, --filter KEY=VALUE | Filter (repeatable) |
--format TEMPLATE | Custom output with Go templates; prefix with table for aligned columns |
Filters
| Filter | Example | Matches |
|---|---|---|
status | status=exited | created, running, paused, restarting, exited, removing, dead |
exited | exited=137 | Exit code |
name | name=web | Part of the name (name=^web$ for an exact match) |
ancestor | ancestor=nginx | Containers created from an image or its descendants |
label | label=env=prod | Label key, or key and value |
health | health=unhealthy | starting, healthy, unhealthy, none |
network, volume | network=shop | Attached network or mounted volume |
publish, expose | publish=8080 | Published or exposed port |
before, since | since=web | Created before/after a given container |
Repeating the same filter key combines values with OR; different keys are combined with AND.
Formatting placeholders. .ID, .Names, .Image, .Command, .CreatedAt, .RunningFor, .Status, .State, .Ports, .Size, .Labels, .Label "key", .Mounts, .Networks. A default format can be saved as "psFormat" in ~/.docker/config.json.
Example
docker ps -a --filter status=exited --filter status=created # exited OR created
docker ps --filter ancestor=nginx --filter publish=8080 # nginx AND publishing 8080
docker ps -a --filter exited=137 # killed by SIGKILL (often OOM)
docker ps --filter health=unhealthy
docker ps --filter 'name=^web$' # exact name, not "web-old"
# Custom table
docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}'
# NAMES IMAGE STATE STATUS PORTS
# web nginx:1.27-alpine running Up 2 hours 0.0.0.0:8080->80/tcp
# job alpine:3.20 exited Exited (1) 3 minutes ago
# Machine-readable output for scripts
docker ps -a --format '{{json .}}' | jq -r 'select(.State=="exited") | .Names'
# IDs feeding into other commands
docker ps -aq --filter status=exited | xargs -r docker rm
docker stop $(docker ps -q --filter label=env=dev)
# Size of each container's writable layer
docker ps -s --format 'table {{.Names}}\t{{.Size}}'
# NAMES SIZE
# web 1.09kB (virtual 47.9MB)
# Persistent default format (merge into ~/.docker/config.json)
# { "psFormat": "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" }
Key takeaways
docker psshows running containers; add-afor all.- Same filter keys are OR-ed, different keys are AND-ed.
-qplus filters is the standard building block for bulk operations.--formatwithtableorjsonmakes output readable for humans or scripts.
🧪 Practice
- (Beginner) List all containers created from
alpineimages that exited with a non-zero code, showing name, exit status, and age.- (Intermediate) Label a few containers with
env=devandenv=test, then stop alldevcontainers and remove all stoppedtestcontainers using onlydocker psfilters and pipes.- (Intermediate) Configure a
psFormatin your Docker config file that shows name, image, state, and ports, and verify thatdocker psuses it by default.
docker stats
Why live statistics. When a host slows down, the first question is "which container is using the CPU, memory, network, or disk?" docker stats shows live resource usage per container, read directly from each container's cgroup, without installing any monitoring stack.
Columns
| Column | Meaning |
|---|---|
CPU % | Share of host CPU; 100% equals one full core, so 250% means two and a half cores |
MEM USAGE / LIMIT | Memory in use (page cache that can be reclaimed is excluded) / the container's limit, or total host memory if none is set |
MEM % | Usage relative to the limit |
NET I/O | Bytes received / sent on the container's network interfaces |
BLOCK I/O | Bytes read / written to block devices |
PIDS | Number of processes and threads |
By default it refreshes continuously for running containers. --no-stream prints one snapshot (useful in scripts), -a includes stopped containers, and --format selects columns (.Name, .CPUPerc, .MemUsage, .MemPerc, .NetIO, .BlockIO, .PIDs).
Limits of docker stats. It shows only the present moment: no history, no alerts, no graphs. For trends and alerting, export the same cgroup metrics with tools such as cAdvisor and Prometheus (see 12.2 Monitoring).
Example
docker run -d --name web --memory 128m nginx:1.27-alpine
docker run -d --name busy alpine:3.20 sh -c 'while :; do :; done' # burns one CPU core
docker stats
# CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
# 7a1c... busy 99.87% 412KiB / 15.5GiB 0.00% 1.1kB / 0B 0B / 0B 1
# 3f2b... web 0.00% 3.2MiB / 128MiB 2.50% 1.3kB / 0B 0B / 4.1kB 9
# ^ explicit limit vs. ^ host memory (no limit set)
# Single snapshot, chosen columns, sorted by memory usage
docker stats --no-stream --format '{{.MemPerc}}\t{{.Name}}\t{{.MemUsage}}' | sort -rn
# Watch specific containers only
docker stats web busy
docker rm -f busy
Key takeaways
docker statsshows live CPU, memory, network, disk I/O, and process counts per container.- CPU % is relative to one core; values above 100% are normal on multi-core hosts.
- The memory limit column shows host memory when no limit is set.
- Use
--no-streamand--formatin scripts; use a monitoring stack for history and alerting.
🧪 Practice
- (Beginner) Start a container that writes a 500 MB file to its filesystem (
dd if=/dev/zero of=/tmp/f bs=1M count=500) and watch theBLOCK I/OandMEM USAGEcolumns.- (Intermediate) Run a CPU-bound container with
--cpus 0.5and another without limits. Compare theirCPU %and explain the numbers.- Interview question: "
docker statsshows a container at 95% of its memory limit, but the application reports using much less. What could explain the difference?" Hint: think about what counts as container memory in cgroups (kernel memory, page cache, tmpfs) and what the application measures.
docker top
Why top for containers. docker exec CONTAINER ps only works if the image contains ps, and it shows PIDs and users as seen inside the container. docker top asks the host's ps to list the processes belonging to the container, so it works for any image (even distroless) and shows the host's view: real PIDs and real UIDs, which is what you need to correlate with host tools, kill, or kernel logs.
Inside versus outside view
docker exec web ps docker top web
(container PID namespace) (host PID namespace)
PID USER COMMAND UID PID PPID CMD
1 root nginx: master root 48211 48190 nginx: master process ...
30 nginx nginx: worker 101 48263 48211 nginx: worker process
^ real host PIDs; 101 is the nginx UID
(host has no name for it)
Syntax. docker top CONTAINER [ps OPTIONS]. Any ps options can be passed, as long as the output keeps a PID column, which Docker uses to select the container's processes.
Example
docker run -d --name web nginx:1.27-alpine
docker top web
# UID PID PPID C STIME TTY TIME CMD
# root 48211 48190 0 10:31 ? 00:00:00 nginx: master process nginx -g daemon off;
# 101 48263 48211 0 10:31 ? 00:00:00 nginx: worker process
# Custom ps columns (must include pid)
docker top web -o pid,ppid,user,rss,args
# PID PPID USER RSS COMMAND
# 48211 48190 root 7800 nginx: master process nginx -g daemon off;
# 48263 48211 101 3200 nginx: worker process
# Works even when the image has no shell and no ps (the pause image contains a single binary)
docker run -d --name tiny registry.k8s.io/pause:3.10
docker exec tiny ps
# OCI runtime exec failed: ... exec: "ps": executable file not found in $PATH
docker top tiny
# UID PID PPID C STIME TTY TIME CMD
# 65535 49102 49080 0 10:33 ? 00:00:00 /pause
# The host PID can be used with host tools
sudo ls -l /proc/48211/ns | head -n 3 # namespaces of the container's PID 1
Key takeaways
docker toplists a container's processes using the host'sps, so it works for every image.- It shows host PIDs and host UIDs, unlike
psrun inside the container. - Extra
psoptions are allowed as long as aPIDcolumn remains.
🧪 Practice
- (Beginner) Run
postgres:16, comparedocker topwithdocker exec ... ps aux(installprocpsif needed), and map each container PID to its host PID.- (Intermediate) Start a container with
-u 4242, then find its process withps -u 4242on the host. Explain why the user column shows a number instead of a name.- Interview question: "Why can the same process have two different PIDs, and which one would you pass to
killon the host?" Hint: think about nested PID namespaces and which namespace your shell belongs to.
docker events
Why an event stream. Many questions are about when and in what order things happened: "Why did this container restart at 3 a.m.?", "Who removed that volume?", "Is a service crash-looping?". docker events streams everything the daemon does in real time and can also replay recent history, which makes it useful both for debugging and as a trigger for automation.
What it reports
| Object type | Example actions |
|---|---|
container | create, start, die, stop, kill, oom, restart, pause, destroy, exec_start, health_status |
image | pull, tag, untag, delete, push |
volume | create, mount, unmount, destroy |
network | create, connect, disconnect, destroy |
daemon | reload |
Options
| Option | Effect |
|---|---|
--since, --until | Replay past events (relative like 30m or timestamps); with --until, the command exits instead of streaming |
-f, --filter | type=, event=, container=, image=, label=, network=, volume= |
--format | Go template over .Type, .Action, .Actor.ID, .Actor.Attributes, .Time; {{json .}} for full objects |
The daemon keeps only a limited buffer of recent events in memory (the last 1000), so it is not a long-term audit log; ship events to a logging system if you need history.
Example
# Terminal 1: watch container lifecycle events
docker events --filter type=container \
--format '{{.Time}} {{.Action}} {{.Actor.Attributes.name}} {{.Actor.Attributes.exitCode}}'
# Terminal 2
docker run --rm --name demo alpine:3.20 sh -c 'exit 3'
# Terminal 1 output (Unix timestamps, then action, name, exit code)
# 1792058461 create demo
# 1792058461 attach demo
# 1792058461 start demo
# 1792058461 die demo 3
# 1792058461 destroy demo
# What happened in the last hour? (exits instead of streaming)
docker events --since 1h --until 0s --filter event=die --filter event=oom
# Detect a crash loop: many die/start pairs for the same container
docker events --since 10m --until 0s --filter container=web --filter event=die | wc -l
# Simple automation: react to every container death
docker events --filter event=die --format '{{json .}}' | while read -r ev; do
name=$(jq -r '.Actor.Attributes.name' <<<"$ev")
code=$(jq -r '.Actor.Attributes.exitCode' <<<"$ev")
[ "$code" != "0" ] && echo "ALERT: $name exited with $code"
done
Key takeaways
docker eventsstreams daemon activity for containers, images, volumes, networks, and the daemon.--since/--untilreplays recent history; filters narrow the stream by type, action, or object.- It is ideal for diagnosing restarts, OOM kills, and crash loops, and for lightweight automation.
- The in-memory history is short; export events if you need an audit trail.
🧪 Practice
- (Beginner) Run
docker eventswhile pulling an image, creating a volume, and creating a network. List every event type and action you observe.- (Intermediate) Add a health check to a container (
--health-cmd 'exit 1' --health-interval 5s) and capture thehealth_statusevents. Write a filter that shows only those.- Interview question: "How would you get notified within seconds whenever any container on a host is killed by the OOM killer, without installing a monitoring stack?" Hint: combine an event filter with a small long-running script, and think about how it should survive daemon restarts.
Pruning Unused Resources
Why pruning is needed. Docker almost never deletes anything on its own. Every build leaves intermediate images and cache, every docker run without --rm leaves a stopped container, every image update leaves the old version behind as a dangling image, and every removed container may leave an anonymous volume. On a development machine or CI runner this easily grows to tens of gigabytes. The prune commands remove these unused objects in bulk.
What each prune command removes
| Command | Removes by default | Wider scope option |
|---|---|---|
docker container prune | All stopped containers | |
docker image prune | Dangling images (untagged, unreferenced: <none>:<none>) | -a: every image not used by a container |
docker volume prune | Unused anonymous volumes | -a: also unused named volumes |
docker network prune | Custom networks not used by any container | |
docker builder prune | Dangling build cache | -a: all build cache |
docker system prune | Stopped containers, unused networks, dangling images, dangling build cache | -a: all unused images and build cache; --volumes: also unused anonymous volumes |
Filters. --filter until=24h keeps recent objects (not available for volumes), and --filter label=... / label!=... targets or protects objects by label. -f skips the confirmation prompt.
Danger zone. "Unused" means "not referenced by an existing container", not "unimportant". A database volume whose container you removed a minute ago is unused. Image pruning with -a will force the next deployment or offline start to pull everything again. Prefer targeted prunes with filters on servers, and protect important objects with labels.
Example
docker system df # see what is reclaimable first (next topic)
docker container prune -f
# Deleted Containers:
# 7a1c...
# Total reclaimed space: 12.4MB
docker image prune # dangling only, asks for confirmation
docker image prune -a --filter until=168h # unused images older than 7 days
# Volumes: anonymous only by default
docker volume prune
docker volume prune -a --filter label!=keep=true # unused named volumes too, except protected ones
docker builder prune --filter until=72h
# Everything at once (common on CI runners)
docker system prune -af --filter until=24h
# Protect a resource with a label, then exclude it
docker volume create --label keep=true pgdata
docker volume prune -af --filter label!=keep=true # pgdata survives
Key takeaways
- Docker accumulates stopped containers, dangling images, unused volumes, networks, and build cache.
- Each object type has its own
prune;docker system prunecombines most of them. - Volume pruning removes only anonymous volumes unless you add
-a. - "Unused" is not "safe to delete": use filters and labels on servers, and check
docker system dffirst.
🧪 Practice
- (Beginner) Run
docker system df, thendocker system prune, thendocker system dfagain. Record how much space was reclaimed per object type.- (Intermediate) Create one anonymous volume, one named volume, and one named volume with
keep=true, remove all containers, and design a single prune command that deletes only the first two.- Interview question: "Design a cleanup policy for a shared CI runner that keeps builds fast but never runs out of disk." Hint: balance build cache retention against disk usage using
untilfilters, scheduled jobs, and BuildKit's own garbage collection settings.
Disk Usage with docker system df
Why measure first. Before deleting anything, you need to know where the space is going. Running du on /var/lib/docker gives confusing results because image layers are shared between images and overlay mounts are counted multiple times. docker system df reports Docker's own accounting, per object type, and estimates how much could be reclaimed.
Reading the output
| Column | Meaning |
|---|---|
TYPE | Images, Containers (writable layers), Local Volumes, Build Cache |
TOTAL | Number of objects |
ACTIVE | Objects in use (images used by containers, volumes mounted by containers, running containers) |
SIZE | Disk space used, counting shared image layers only once |
RECLAIMABLE | Space that pruning could free (objects not in use) |
-v (verbose) breaks the totals down per image, container, volume, and cache entry. For images it shows SHARED SIZE (layers shared with other images) and UNIQUE SIZE (what removing that image alone would free), which is the number that matters when deciding what to delete.
What it does not include. Container log files (under /var/lib/docker/containers/) are not part of these numbers; check them separately with du or by configuring log rotation. On Docker Desktop, the data lives inside a VM disk image, and the space freed inside the VM may not immediately be returned to the host's filesystem.
Example
docker system df
# TYPE TOTAL ACTIVE SIZE RECLAIMABLE
# Images 14 3 3.12GB 2.41GB (77%)
# Containers 6 2 84.2MB 84.1MB (99%)
# Local Volumes 5 1 1.37GB 968MB (70%)
# Build Cache 52 0 1.9GB 1.9GB
docker system df -v
# Images space usage:
# REPOSITORY TAG IMAGE ID CREATED SIZE SHARED SIZE UNIQUE SIZE CONTAINERS
# postgres 16 a1b2c3d4e5f6 2 weeks ago 453MB 74.8MB 378MB 1
# python 3.13-slim 0f9e8d7c6b5a 3 weeks ago 124MB 74.8MB 49.6MB 0
# ...
# Containers space usage:
# CONTAINER ID IMAGE COMMAND LOCAL VOLUMES SIZE CREATED STATUS NAMES
# 9a8b... alpine:3.20 "sh -c…" 0 84MB 1 hour ago Exited (0) bigwriter
# ...
# Local Volumes space usage:
# VOLUME NAME LINKS SIZE
# pgdata 1 398MB
# ...
# Per-type structured output for scripts
docker system df --format '{{.Type}}: {{.Size}} ({{.Reclaimable}} reclaimable)'
# Logs are not included: measure them separately
sudo du -sh /var/lib/docker/containers/*/*-json.log 2>/dev/null | sort -h | tail -n 3
Key takeaways
docker system dfshows space used per object type, counting shared layers once.RECLAIMABLEis what pruning unused objects would free; check it before pruning.-vshows per-object detail, including the unique size of each image.- Container logs are not counted; monitor and rotate them separately.
🧪 Practice
- (Beginner) Run a container that writes a 100 MB file into its filesystem, stop it, and find it in
docker system df -v. Then remove it and confirm the change.- (Intermediate) Pull three images that share a base (for example
python:3.13-slim,python:3.12-slim, anddebian:12-slim). Usedocker system df -vto explain why the sum ofSIZEvalues exceeds the total reported for images.- Interview question: "A server's disk is filling up, but
docker system dfshows little reclaimable space. Where else would you look?" Hint: consider container logs, data written into containers' writable layers by running containers, volumes in use, and files outside Docker's accounting.
<a id="4-docker-images"></a>