Skip to main content
Docker · lesson 3 of 9
Docker beginner Lesson 3 of 9

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

  1. Run nginx:1.27-alpine with -P instead of -p and 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.

  1. The stubborn container took 10 seconds to stop. Make docker stop give 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.

  1. 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.

Frequently Asked Questions

What is the difference between docker stop and docker kill?
docker stop sends SIGTERM so the main process can shut down cleanly, then sends SIGKILL if it is still running after a grace period (10 seconds by default, -t to change it). docker kill sends SIGKILL immediately. A container killed by SIGKILL exits with code 137.
Why do my changes inside a container disappear?
Changes live in the container's writable layer. They survive docker stop and docker restart, but docker rm deletes that layer, and a new container from the same image starts clean. Keep data you need in a volume or bind mount instead.
Why does docker stop take 10 seconds for some containers?
The main process runs as PID 1 inside the container, and the kernel does not apply default signal actions to PID 1. If the program does not install a SIGTERM handler, the signal is ignored and Docker waits out the grace period before sending SIGKILL. Handle SIGTERM in your app or run it with docker run --init.
What does -p 8080:80 mean?
It publishes container port 80 on host port 8080, so requests to localhost:8080 on the host reach the process listening on port 80 inside the container. The order is always host:container. -P publishes every exposed port on a random free host port instead.