Before you begin
Install Docker Engine or Docker Desktop using Docker’s instructions for your OS. Start the engine and run docker version. You need both client and server information. Use a disposable learning directory; these labs create only the named resources.
Working toward: Ship a small service with a reproducible image, persistent data, health checks, and an operating runbook.
Read each explanation, run the example in your own lab, and attempt the exercise before opening its answer. Published lessons are ready to study; unfinished roadmap topics remain planned.
Validation: New lessons 5–7 were checked against current Docker documentation on 14 September 2026. The exact Python HTTP-handler behavior and worker SIGTERM/cleanup path were executed independently with Python 3.12.14. Dockerfile/command structure was inspected; Docker Engine, image builds, container health transitions, restart policies, cgroups and read-only container mounts were not execution-tested because Docker is unavailable here.
1. Image versus container
An image is a packaged filesystem and runtime configuration. A container is an instance with processes and a writable layer. Containers share a host kernel; they are not complete virtual machines. Docker may itself run Linux containers inside a VM on your desktop.
docker run --name auwen-web -d -p 127.0.0.1:8080:80 nginx:stable
docker ps
docker logs auwen-webWhat to expect
Open http://localhost:8080 to see nginx. The published port is bound to your own computer’s loopback address.
Your turn
Identify the image, container name, host port, and container port.
Show answer and reasoning
Image: nginx:stable. Name: auwen-web. Host port: 8080. Container port: 80. Clean up with docker rm -f auwen-web when finished.Watch for: A tag such as stable can change. Production reproducibility needs a reviewed version or digest. Binding to 0.0.0.0 can expose a service to other machines.
Link to this lesson2. Build your own image
A Dockerfile describes image construction. COPY takes a file from the build context and puts it into the image. Create index.html containing a short welcome message, then save this Dockerfile in the same directory.
FROM nginx:stable
COPY index.html /usr/share/nginx/html/index.htmlRun it
docker build -t auwen-page:lab .
docker run --rm --name auwen-page -p 127.0.0.1:8081:80 auwen-page:labWhat to expect
http://localhost:8081 shows your message. The foreground container logs requests; Ctrl+C stops it and --rm removes the container.
Your turn
Change index.html. Does the running container change immediately? Rebuild and start it again.
Show answer and reasoning
No. COPY captured the file during the image build. Stop the container, rebuild with the same command, and run a new instance.Watch for: Do not COPY credentials into images. Removing a secret in a later layer does not remove it from an earlier image layer.
Link to this lesson3. Keep data outside a container
A named volume persists independently of a container’s writable layer. This lab mounts one volume at /data in two successive containers. The second container reads what the first wrote.
docker volume create auwen-lab-data
docker run --rm -v auwen-lab-data:/data alpine:3 sh -c "echo saved > /data/note.txt"
docker run --rm -v auwen-lab-data:/data alpine:3 cat /data/note.txtWhat to expect
saved
Your turn
Explain why removing the first container did not remove note.txt. Then remove the lab volume after inspecting it.
Show answer and reasoning
The data belongs to auwen-lab-data. Clean up with docker volume rm auwen-lab-data once no container uses it.Watch for: Persistence is not a backup. A process with access to the volume can corrupt or delete its contents.
Link to this lesson4. Describe the service in YAML
Compose records configuration so you can reproduce it without remembering a long command. Save this as compose.yaml in a new directory. Mapping quotes preserve the port specification as a string.
services:
web:
image: nginx:stable
ports:
- "127.0.0.1:8082:80"Run it
docker compose config
docker compose up -d
docker compose ps
docker compose logs web
# When finished:
docker compose downWhat to expect
The configuration validates, the web service starts, and http://localhost:8082 responds.
Your turn
Change the host port to 8083 and recreate the service. Explain why the container port remains 80.
Show answer and reasoning
nginx listens inside the container on 80; only the host-side mapping changes. Run docker compose up -d again and visit port 8083.Watch for: YAML indentation is significant. docker compose down removes the project’s containers and networks; adding -v also removes its declared named volumes.
Link to this lesson5. Make health an observation, not a guess
Before this lesson: Complete Docker run/build/volume/Compose foundations. Use Docker Engine/Desktop in Linux-container mode and a fresh folder. Python knowledge from the first Python lessons helps you read the tiny server, but no host Python installation is needed to build the image. Outcome: observe running-but-unhealthy behavior and repair its cause.
A running container tells you that its main process has not exited. It does not prove that the application can serve useful requests. A health check periodically executes a command inside the container; its exit status becomes a separate health observation. Our check calls /ready and fails when the server answers 503 or the request fails. It uses Python already present in the image instead of assuming curl was installed.
Create the two files below. This intentionally small HTTP server has /live and /ready endpoints. The readiness marker /tmp/auwen-unready simulates a dependency being unavailable while the process remains alive. It is a controlled failure switch, not a production readiness design. USER 10001 runs the server as an unprivileged numeric UID; the app files are readable and port 8080 does not require a privileged port bind.
The image starts with health state starting, then becomes healthy after a successful check. Consecutive failures outside the startup allowance can make it unhealthy. interval controls spacing, timeout bounds one check, retries sets consecutive failures and start-period allows initial startup. Exact transition timing includes scheduling and check duration; do not use a stopwatch claim as the contract.
A standalone Docker health check does not automatically restart an unhealthy container. A restart policy reacts to process exits under its rules. The failure-and-repair commands deliberately keep the same process alive so you can see that distinction. EXPOSE documents the container port; the docker run publication below is what maps the loopback host port.
app.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/live":
status, body = 200, b"alive\n"
elif self.path == "/ready":
ready = not Path("/tmp/auwen-unready").exists()
status = 200 if ready else 503
body = b"ready\n" if ready else b"not ready\n"
else:
status, body = 200, b"AUWEN lab\n"
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY app.py /app/app.py
USER 10001
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=2s --start-period=5s --retries=2 CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/ready', timeout=1).read()"]
CMD ["python", "-u", "/app/app.py"]
docker build -t auwen-health:lab .
docker run -d --name auwen-health -p 127.0.0.1:8085:8080 auwen-health:lab
docker inspect --format '{{.State.Status}} / {{.State.Health.Status}}' auwen-health
docker inspect --format '{{json .State.Health.Log}}' auwen-health
# Simulate readiness failure without stopping the server:
docker exec auwen-health python -c "from pathlib import Path; Path('/tmp/auwen-unready').touch()"
# Wait for repeated checks, then inspect again:
docker inspect --format '{{.State.Status}} / {{.State.Health.Status}}' auwen-health
# Repair and observe recovery:
docker exec auwen-health python -c "from pathlib import Path; Path('/tmp/auwen-unready').unlink()"
docker inspect --format '{{.State.Status}} / {{.State.Health.Status}}' auwen-healthRun it
Build with the two files in an otherwise-empty context. Visit http://localhost:8085/ready and /live. Re-run inspect after enough check intervals to observe each transition. Inspect recent check logs when the result differs from your prediction. Use docker rm -f auwen-health to remove this named lab container when finished; keep the image for lesson 7.What to expect
Initially /ready returns ready and health eventually becomes healthy. Creating the marker makes /ready return HTTP 503 while /live still returns HTTP 200. The container remains running but eventually becomes unhealthy. Removing the marker lets a later check return it to healthy without creating a new container. No application recovery beyond the simulated marker is being demonstrated.
Your turn
Keep the marker present and run docker inspect to record status, health and restart count. Would --restart unless-stopped alone repair this condition? Change the Dockerfile's probe path to /live in a separate image tag, predict how that changes the meaning of healthy, and explain why it might hide an unavailable dependency.
Show answer and reasoning
docker inspect --format '{{.State.Status}} {{.State.Health.Status}} {{.RestartCount}}' auwen-health
# A running-but-unhealthy process has not exited, so the standalone
# restart policy does not repair this marker condition.
# Probing /live tests that this server can answer its liveness
# endpoint. It no longer asks whether the simulated dependency is
# ready; /ready can be 503 while health says healthy.
# Checkpoint: write one sentence defining precisely what YOUR
# health check proves, and one thing it does not prove.Watch for: Do not copy a secret into a health-check command: check output can be visible in inspection. A probe needs its executable and dependencies inside the image. Mutable python:3.12-slim is convenient for learning; record the pulled digest for reproducibility. This single-threaded teaching server is not a production web server.
Lesson references
- Dockerfile HEALTHCHECK, USER and exec-form CMD; checked 14 September 2026 ↗
- Docker restart-policy behavior ↗
- Python HTTP server behavior ↗
6. Let a process stop before forcing it to disappear
Before this lesson: Complete the health-check lesson and use a separate folder for these two files. Docker must run Linux containers. Outcome: distinguish graceful termination, forced termination, exit status and restart policy, and verify that cleanup actually ran.
A container's main process controls its lifetime. In Linux containers, docker stop sends the configured stop signal (normally SIGTERM), waits for the timeout, then uses SIGKILL if the process has not exited. SIGTERM gives a cooperating program an opportunity to finish; SIGKILL cannot be handled by application code. Forced removal is therefore not evidence that cleanup succeeded.
The worker below installs a SIGTERM handler. The handler sets a flag; the loop notices it and reaches the cleanup message. It does not perform a real database flush, but gives you observable evidence of a completed path. flush=True makes the messages visible promptly. The Dockerfile's JSON/exec-form CMD starts Python directly, without an extra shell swallowing or failing to forward signals.
Use no automatic restart policy for the first test. If the process keeps coming back while you are learning termination, it becomes harder to tell which instance produced a log line. Retain the stopped container long enough to inspect its logs and exit state, then remove it by exact name.
For services that should return after failure, a restart policy may be useful—but its conditions matter. on-failure responds to nonzero exit status; unless-stopped and always have different behavior after manual stops and daemon restarts. Docker also applies restart monitoring rules. Record the policy and actual state rather than inferring one from the other.
worker.py
import signal
import time
stopping = False
def request_stop(signum, frame):
global stopping
stopping = True
print("stop requested", flush=True)
signal.signal(signal.SIGTERM, request_stop)
print("worker ready", flush=True)
while not stopping:
time.sleep(0.1)
print("cleanup complete", flush=True)
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY worker.py /app/worker.py
USER 10001
CMD ["python", "-u", "/app/worker.py"]
docker build -t auwen-worker:lab .
docker run -d --name auwen-worker auwen-worker:lab
docker logs auwen-worker
# Wait until "worker ready" is visible, then:
docker stop --timeout 5 auwen-worker
docker logs auwen-worker
docker inspect --format '{{.State.ExitCode}} / {{.State.OOMKilled}}' auwen-worker
# Separate forced-stop experiment:
docker run -d --name auwen-worker-kill auwen-worker:lab
docker logs auwen-worker-kill
# Wait until ready:
docker kill --signal KILL auwen-worker-kill
docker logs auwen-worker-kill
docker inspect --format '{{.State.ExitCode}} / {{.State.OOMKilled}}' auwen-worker-kill
# Remove only these stopped lab containers:
docker rm auwen-worker auwen-worker-killRun it
Do not send the signal before worker ready appears: the handler is installed before that message, so it is your synchronization point. Compare both log histories and the exit/OOM fields. SIGKILL here is deliberate; no memory-pressure experiment is required.What to expect
The stop case should log worker ready, stop requested, cleanup complete, then exit 0. The deliberate KILL case cannot run the signal handler or cleanup path; Docker commonly reports 137 (128 + signal 9). OOMKilled should be false in this forced-kill experiment. An exit code of 137 alone does not establish an out-of-memory cause.
Your turn
Change the worker in a COPY so it ignores SIGTERM by installing signal.SIG_IGN instead of request_stop. Predict what docker stop --timeout 2 will eventually do and what cleanup evidence will be absent. Would a log saying 'container stopped' prove your application completed its own final write?
Show answer and reasoning
# In the experimental copy only:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
# The worker continues running after TERM. After the stop timeout,
# Docker can force KILL; cleanup complete will not be emitted.
# Container stopped describes runtime state, not successful
# application cleanup. Restore the original handler after the test.
# Checkpoint: explain why increasing the timeout cannot repair an
# application that permanently ignores its shutdown request.Watch for: Do not replace exec-form CMD with a shell wrapper unless you understand signal forwarding and use exec appropriately. Avoid --rm when you need to inspect a stopped container. Logs can be buffered or incomplete; critical recovery requires application-specific evidence. This Python handler was tested as a normal subprocess here, not as PID 1 inside Docker.
Lesson references
- Docker container stop: signals and timeout ↗
- Docker container kill ↗
- Docker restart policies ↗
- Python signal handling ↗
7. Bound resource use and name the writable paths
Before this lesson: Complete Docker health checks and process lifecycle. Reuse the auwen-health:lab image from lesson 5 on a Linux-container Docker host. Outcome: inspect CPU/memory/PID limits, distinguish a read-only root filesystem from explicitly writable storage, and avoid treating a limit as a capacity guarantee.
By default a container may compete for host resources without the limits you intended. --cpus sets a CPU bandwidth constraint, not a reservation of dedicated cores. --memory bounds container memory accounting; exceeding available memory can cause allocation failure or a killed process. --pids-limit restricts the number of processes/threads accounted by the relevant controller. A limit that is too small can cause a healthy application to fail.
Resource flags depend on host support and cgroup configuration. docker stats shows observations, not proof that every desired policy is enforced. Inspect the configured values and read daemon warnings. This lesson does not intentionally exhaust the host; its point is to learn how a bounded workload is described and how evidence is gathered.
A read-only root filesystem prevents ordinary application writes into the image's writable layer. Programs often still need temporary space, so we explicitly mount a small tmpfs at /tmp. It is writable, memory-backed and ephemeral, not a persistent volume or backup. Its usage counts toward the container memory limit, and the host may swap its pages; it is not guaranteed never to touch disk. Our health marker lives there, which lets us retain the previous failure drill without making /app writable.
The image already runs as UID 10001. Dropping Linux capabilities further reduces capabilities available to its processes, but neither this nor read-only mode is a complete security boundary. Name the intended writes first, then choose a volume or tmpfs for those paths; do not make the whole filesystem writable merely to hide an error.
docker run -d --name auwen-bounded -p 127.0.0.1:8086:8080 --cpus 0.5 --memory 128m --pids-limit 64 --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m,mode=1777 --cap-drop ALL auwen-health:lab
docker inspect --format '{{.HostConfig.NanoCpus}} {{.HostConfig.Memory}} {{.HostConfig.PidsLimit}} {{.HostConfig.ReadonlyRootfs}}' auwen-bounded
docker stats --no-stream auwen-bounded
# Intended temporary write succeeds:
docker exec auwen-bounded python -c "from pathlib import Path; Path('/tmp/auwen-unready').touch()"
# A root-filesystem write is denied:
docker exec auwen-bounded python -c "from pathlib import Path; Path('/app/test.txt').write_text('test')"
# Restore readiness:
docker exec auwen-bounded python -c "from pathlib import Path; Path('/tmp/auwen-unready').unlink()"
docker inspect --format '{{.State.Health.Status}}' auwen-bounded
docker rm -f auwen-boundedRun it
Use the exact image from lesson 5. Inspect the daemon's warnings and recheck health after the temporary marker is removed. The /app write failure is intentional; a non-root ownership restriction may also deny that write, so inspect ReadonlyRootfs rather than claiming that one error proves which protection was responsible.What to expect
Configured values include NanoCpus 500000000, Memory 134217728, PidsLimit 64 and ReadonlyRootfs true. stats reports changing use, not those exact usage numbers. The /tmp marker can be created and removed; the /app write fails. The server remains available within its modest limits on a suitable host. Docker/cgroup enforcement was not executed here.
Your turn
If a service writes a growing upload into /tmp, what happens to that data when its container is removed? If a workload uses half a CPU continuously, does --cpus 0.5 guarantee low latency? Explain why exit 137 should be investigated with OOMKilled and logs rather than immediately labeled a memory leak.
Show answer and reasoning
# tmpfs data disappears with the container; persist required uploads
# using deliberately managed storage and a tested backup procedure.
# A CPU quota is a ceiling, not reserved capacity or a latency SLA.
# Scheduling, contention, throttling and application design matter.
# Exit 137 can result from SIGKILL; inspect State.OOMKilled, events,
# application logs and host memory evidence before assigning a cause.
# Checkpoint: list every writable path your own app needs and classify
# it as temporary, persistent/recoverable, or unnecessary.Watch for: A memory limit is not a complete swap policy; swap behavior has its own settings and host dependencies. Small PID limits can break probes or worker spawning. A read-only container can still write through writable mounts. Mutable tags and host-level policy need separate review. Do not run a stress test against a shared or production machine.
Lesson references
Link to this lessonPath to advanced
In-progress stages identify the lessons already published. All other listed topics remain planned. Each addition needs teaching, a reproducible lab, failure cases, and a checkpoint before the capstone.
Build and harden
Multi-stage images, non-root users, image provenance, vulnerability management, and secrets.
PLANNEDOperate
Published: health checks, process signals/restart behavior, resource limits and a read-only runtime drill (lessons 5–7). Planned: multi-service networks, logs/monitoring in depth, backups and recovery drills.
IN PROGRESS · PUBLISHED LESSONS ABOVEDeliver
Registries, CI, pinned dependencies, and versioned releases.
PLANNEDCapstone
Containerize a small API and database, document restore procedures, and migrate the workload into the Kubernetes labs.
PLANNED
References
Original AUWEN lessons, with upstream documentation for further study and version checks.
All learning paths and update notes →