Docker · lesson 3 of 9
Intermediate
3Also part of Docker
5- + Deploying FastAPI with Docker and Kubernetes: Images, Signals, and Probes from FastAPI
- + Dockerizing Spring Boot Applications from Java
- + Bash Process Management from Bash
- + CIS Docker Benchmark Checks with docker-bench-security from Cloud Security
- + Signing Container Images with Cosign: Verify the Digest, Not the Tag from DevSecOps (AppSec in the pipeline)
Managing Docker Containers
Run, inspect, stop and remove containers: the lifecycle, port publishing, exec, logs, docker stop versus SIGKILL, memory limits and docker cp.
By SkillByExample · Updated
A container moves through a small set of states — created, running, exited — until it
is removed. This lesson drives each transition with alpine:3.20 and nginx:1.27-alpine.
The lifecycle
docker create --name demo alpine:3.20 echo hi
docker ps -a --filter name=demo --format '{{.Names}}: {{.Status}}'
docker start -a demo
docker ps -a --filter name=demo --format '{{.Names}}: {{.Status}}'
docker rm demo
$ docker create --name demo alpine:3.20 echo hi
5d21b982caefade6a96a7378eef07857ac1179504a992a4422f79e55b066a12a
$ docker ps -a --filter name=demo --format '{{.Names}}: {{.Status}}'
demo: Created
$ docker start -a demo
hi
$ docker ps -a --filter name=demo --format '{{.Names}}: {{.Status}}'
demo: Exited (0) Less than a second ago
$ docker rm demo
demo
docker run is create + start in one step. create builds the container (filesystem,
config) without running it; start -a runs it and attaches to its output. When the main process
exits, the container is Exited, not gone — docker ps hides it, docker ps -a shows it, and
only docker rm deletes it.
Run a server and publish a port
docker run -d --name web -p 8080:80 nginx:1.27-alpine
docker ps --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}'
curl -s localhost:8080 | grep -o '<title>.*</title>'
docker port web
docker logs web 2>&1 | tail -2
$ docker run -d --name web -p 8080:80 nginx:1.27-alpine
fa1eb3ef71dff41edc34ef032e61d4ae60129aeea961f3216397b08dc3dae026
$ docker ps --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}'
web nginx:1.27-alpine Up Less than a second 0.0.0.0:8080->80/tcp
$ curl -s localhost:8080 | grep -o '<title>.*</title>'
<title>Welcome to nginx!</title>
$ docker port web
80/tcp -> 0.0.0.0:8080
$ docker logs web 2>&1 | tail -2
2026/10/07 23:35:58 [notice] 1#1: start worker process 23
172.17.0.1 - - [07/Oct/2026:23:35:58 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.5.0" "-"
-d runs detached and prints the container ID. -p 8080:80 is host:container: nginx listens
on port 80 inside its own network namespace, and Docker forwards host port 8080 to it. docker logs shows whatever the main process wrote to stdout/stderr — including the request curl just
made, arriving from 172.17.0.1, the host side of Docker’s default bridge.
Changes live in the container’s writable layer
docker run -d --name web -p 8080:80 nginx:1.27-alpine
docker exec web sh -c 'echo "<h1>edited live</h1>" > /usr/share/nginx/html/index.html'
curl -s localhost:8080
docker diff web | grep html
docker restart web
docker exec web cat /usr/share/nginx/html/index.html
docker rm -f web
docker run -d --name web -p 8080:80 nginx:1.27-alpine
docker exec web grep -o '<title>.*</title>' /usr/share/nginx/html/index.html
docker rm -f web
$ docker run -d --name web -p 8080:80 nginx:1.27-alpine
e7b63e0272327085301592a6f3e0194adb84f8e9599864f6a6a92961198b11d1
$ docker exec web sh -c 'echo "<h1>edited live</h1>" > /usr/share/nginx/html/index.html'
$ curl -s localhost:8080
<h1>edited live</h1>
$ docker diff web | grep html
C /usr/share/nginx/html
C /usr/share/nginx/html/index.html
$ docker restart web
web
$ docker exec web cat /usr/share/nginx/html/index.html
<h1>edited live</h1>
$ docker rm -f web
web
$ docker run -d --name web -p 8080:80 nginx:1.27-alpine
6661e5915b156b0ae7b5dff5882982384495c27119e7ce4b09aa50ab7983777d
$ docker exec web grep -o '<title>.*</title>' /usr/share/nginx/html/index.html
<title>Welcome to nginx!</title>
$ docker rm -f web
web
docker exec runs an extra process inside a running container. The edit went into the container’s
writable layer — docker diff lists it as C (changed). It survives a restart, because the
container (and its layer) still exists. docker rm -f deletes the layer, and a new container from
the same image starts with the original page. Data you want to keep belongs in a volume (see the volumes
lesson).
Environment variables and inspect
docker run -d --name cfg -e APP_MODE=staging -e LOG_LEVEL=debug alpine:3.20 sleep 300
docker exec cfg printenv APP_MODE LOG_LEVEL
docker inspect --format '{{.State.Status}} {{.Config.Cmd}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' cfg
docker rm -f cfg
$ docker run -d --name cfg -e APP_MODE=staging -e LOG_LEVEL=debug alpine:3.20 sleep 300
c944d67bf358486290e93575ec93161894acdfa3da0f60c221c0ad716c718336
$ docker exec cfg printenv APP_MODE LOG_LEVEL
staging
debug
$ docker inspect --format '{{.State.Status}} {{.Config.Cmd}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' cfg
running [sleep 300] 172.17.0.2
$ docker rm -f cfg
cfg
-e sets environment variables for this container only — the image is unchanged. docker inspect returns everything Docker knows about a container as JSON; --format takes a Go template
to pull out single fields. The IP address sits under each attached network, so the template ranges
over .NetworkSettings.Networks.
docker stop: SIGTERM first, SIGKILL later
docker run -d --name stubborn alpine:3.20 sleep 300
time docker stop stubborn
docker inspect --format '{{.State.ExitCode}}' stubborn
docker run -d --name polite alpine:3.20 sh -c 'trap "echo got SIGTERM, cleaning up; exit 0" TERM; while :; do sleep 1; done'
time docker stop polite
docker logs polite
docker inspect --format '{{.State.ExitCode}}' polite
docker rm stubborn polite
$ docker run -d --name stubborn alpine:3.20 sleep 300
3133163e63d91e35feaab52bf8294e59364b494fd49c80552074d062c0395e58
$ time docker stop stubborn
stubborn
real 0m10.190s
user 0m0.018s
sys 0m0.014s
$ docker inspect --format '{{.State.ExitCode}}' stubborn
137
$ docker run -d --name polite alpine:3.20 sh -c 'trap "echo got SIGTERM, cleaning up; exit 0" TERM; while :; do sleep 1; done'
53098a2d6691e2532e52246953563a722cc619874bcc8774fe754f643d1a1785
$ time docker stop polite
polite
real 0m1.115s
user 0m0.011s
sys 0m0.016s
$ docker logs polite
got SIGTERM, cleaning up
$ docker inspect --format '{{.State.ExitCode}}' polite
0
$ docker rm stubborn polite
stubborn
polite
docker stop sends SIGTERM, waits up to 10 seconds, then sends SIGKILL. sleep runs as PID 1,
and the kernel gives PID 1 no default signal actions, so without a handler SIGTERM is simply
ignored: the stop took the full 10 seconds and ended in SIGKILL (exit code 137 = 128 + 9). The
polite shell installs a trap, cleans up and exits 0 at the next loop tick. docker kill skips
straight to SIGKILL. Real apps should handle SIGTERM, or be started with docker run --init.
Limit memory and CPU
docker run -d --name capped --memory 64m --cpus 0.5 alpine:3.20 sleep 300
docker inspect --format 'memory={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}}' capped
docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' capped
docker rm -f capped
docker run --rm --memory 32m python:3.12-alpine python -c "b = bytearray(100 * 1024 * 1024)"; echo "exit code $?"
$ docker run -d --name capped --memory 64m --cpus 0.5 alpine:3.20 sleep 300
88b96a5582145997ee88f62e653f76081b22b9c64285671b239567ee1c21f939
$ docker inspect --format 'memory={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}}' capped
memory=67108864 nanocpus=500000000
$ docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' capped
capped 712KiB / 64MiB
$ docker rm -f capped
capped
$ docker run --rm --memory 32m python:3.12-alpine python -c "b = bytearray(100 * 1024 * 1024)"; echo "exit code $?"
exit code 137
Without limits a container may use all of the host’s memory and CPU. --memory and --cpus set
cgroup limits; docker stats shows usage against the limit. The Python process tried to allocate
100 MB inside a 32 MB limit, and the kernel’s OOM killer sent SIGKILL — the same exit code 137,
with no Python traceback, because the process never got a chance to raise MemoryError.
Copy files in and out
docker run -d --name box alpine:3.20 sleep 300
echo "from the host" > note.txt
docker cp note.txt box:/tmp/note.txt
docker exec box cat /tmp/note.txt
docker cp box:/etc/alpine-release .
cat alpine-release
docker rm -f box
$ docker run -d --name box alpine:3.20 sleep 300
b0af6e4df551de4b76a10daa1cdfa12e752a689edc6a1bd5529f7771f56ab38a
$ echo "from the host" > note.txt
$ docker cp note.txt box:/tmp/note.txt
$ docker exec box cat /tmp/note.txt
from the host
$ docker cp box:/etc/alpine-release .
$ cat alpine-release
3.20.10
$ docker rm -f box
box
docker cp works in both directions, using container:path for the container side. It is handy
for pulling a log or core dump out of a broken container, but it is not a deployment mechanism —
files you need every time belong in the image.
Clean up stopped containers
docker run --name leftover1 alpine:3.20 true
docker run --name leftover2 alpine:3.20 true
docker container prune -f
$ docker run --name leftover1 alpine:3.20 true
$ docker run --name leftover2 alpine:3.20 true
$ docker container prune -f
Deleted Containers:
538499d075de11673f2ab527754b92ca1183f881478d85df6d6de53d9f5312dc
a51b37735debff4e8ee7b832483bce6e9f01f5a32fa14a8cd30e80040200bf68
Total reclaimed space: 8.192kB
Every docker run without --rm leaves an exited container behind. docker container prune
removes all stopped containers; -f skips the confirmation prompt. docker system prune goes
further and also removes unused networks, dangling images and build cache.
Practice
- Run
nginx:1.27-alpinewith-Pinstead of-pand find out which host port Docker picked for container port 80.
Solution
docker run -d --name web2 -P nginx:1.27-alpine
docker port web2 80/tcp
docker rm -f web2
$ docker run -d --name web2 -P nginx:1.27-alpine
c5073ece092ede224c4f42ec1c1c998b2bc0cebc820560e7f1a61bcb67a13718
$ docker port web2 80/tcp
0.0.0.0:32768
$ docker rm -f web2
web2
-P publishes every port the image EXPOSEs on a random free host port, usually from 32768 up.
- The
stubborncontainer took 10 seconds to stop. Makedocker stopgive up after 2 seconds instead.
Solution
docker run -d --name stubborn alpine:3.20 sleep 300
time docker stop -t 2 stubborn
docker rm stubborn
$ docker run -d --name stubborn alpine:3.20 sleep 300
af43b3b3933909ed7ec903b26d0e0831fa0855f6a1a1f6fe8763352281b67b19
$ time docker stop -t 2 stubborn
stubborn
real 0m2.160s
user 0m0.000s
sys 0m0.029s
$ docker rm stubborn
stubborn
-t sets the grace period between SIGTERM and SIGKILL.
- Run a container that always fails (
sh -c 'echo attempt; exit 1') with a restart policy that retries it at most 3 times. After a few seconds, check how many restarts happened.
Solution
docker run -d --name flaky --restart on-failure:3 alpine:3.20 sh -c 'echo attempt; exit 1'
# wait a few seconds for the retries
docker inspect --format '{{.RestartCount}} {{.State.Status}} exit={{.State.ExitCode}}' flaky
docker logs flaky
docker rm flaky
$ docker run -d --name flaky --restart on-failure:3 alpine:3.20 sh -c 'echo attempt; exit 1'
6099c14b9917ba78ccd215cf8c77538093d06c798c5c55d89af829569eac5b64
$ docker inspect --format '{{.RestartCount}} {{.State.Status}} exit={{.State.ExitCode}}' flaky
3 exited exit=1
$ docker logs flaky
attempt
attempt
attempt
attempt
$ docker rm flaky
flaky
The first run plus three restarts gives four attempt lines; then Docker gives up and leaves it
exited. Restarted runs reuse the same container, so the logs accumulate. --restart unless-stopped is the usual choice for long-running services.