Docker & ContainersXXXVIII Β· CapstoneCapstone
Capstone β the production Docker environment
What you'll learn
- Design and deploy a production-style Docker environment
- Cover security, observability, backup, DR
- Validate the design against the production-readiness checklist
- Demonstrate measurable Definition of Done
Prerequisites
- Linux namespaces, cgroups, OverlayFS
- BuildKit multi-stage builds
- Compose production patterns
- Networking and storage operations
- Observability stack
- Backup and restore
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-09
The capstone is a single environment that exercises every lesson in the course. It is not a complete production system; it is a template that demonstrates the patterns.
The architecture
flowchart TB
subgraph Internet
User[User]
end
subgraph Edge[Edge]
RP[Caddy / Traefik<br/>TLS termination]
end
subgraph App[Application]
Web[Web service]
API[API service]
Worker[Background worker]
end
subgraph Data[Data plane]
DB[(Postgres)]
Cache[(Redis)]
end
subgraph Obs[Observability]
Prom[Prometheus]
Grafana
Loki
Tempo
end
Internet --> RP
RP --> Web
RP --> API
API --> DB
API --> Cache
Worker --> DB
Prom --> Web
Prom --> API
Prom --> Worker
Prom --> DB
Prom --> Cache
Prom --> RP
Grafana --> Prom
Grafana --> Loki
Grafana --> Tempo
Whatβs in the environment
- Reverse proxy (Caddy or Traefik). TLS termination, ACME, routing by hostname.
- Web + API + worker services. Each runs as a Compose service with appropriate limits.
- Postgres + Redis. Stateful services with named volumes and backups.
- Prometheus + Grafana + Loki + Tempo. The standard observability stack.
The compose file
# compose.yml
services:
caddy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
user: "0:0" # Caddy needs root for :80/:443; reviewed acceptable
read_only: true
# /data holds the ACME account key and issued certificates and MUST be
# persistent - see the callout below. Only genuinely scratch paths go
# on tmpfs.
tmpfs: ["/tmp"]
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
web:
image: myorg/web:1.0.0
networks: [app-net]
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
user: "10001:10001"
read_only: true
tmpfs: ["/tmp", "/var/cache/myapp"]
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
api:
image: myorg/api:1.0.0
environment:
DATABASE_URL_FILE: /run/secrets/db_password
secrets: [db_password]
networks: [app-net, db-net]
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/live"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
user: "10001:10001"
read_only: true
tmpfs: ["/tmp", "/var/cache/myapp"]
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
worker:
image: myorg/worker:1.0.0
networks: [db-net]
depends_on:
api:
condition: service_healthy
user: "10001:10001"
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
deploy:
replicas: 2
resources:
limits:
memory: 256M
cpus: "0.5"
db:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes:
- db-data:/var/lib/postgresql/data
networks: [db-net]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
user: "999:999" # postgres user in the image
security_opt:
- no-new-privileges:true
cache:
image: redis:7-alpine
networks: [app-net, db-net]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 3
user: "999:999"
security_opt:
- no-new-privileges:true
prometheus:
image: prom/prometheus:v2.55.0
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prom-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
ports: ["9090:9090"]
networks: [obs-net]
user: "65534:65534" # nobody
security_opt:
- no-new-privileges:true
grafana:
image: grafana/grafana:11.3.0
environment:
GF_SECURITY_ADMIN_PASSWORD_FILE: /run/secrets/grafana_admin
secrets: [grafana_admin]
ports: ["3000:3000"]
networks: [obs-net]
loki:
image: grafana/loki:3.3.0
command: -config.file=/etc/loki/local-config.yaml
volumes:
- loki-data:/loki
networks: [obs-net]
secrets:
db_password:
file: ./secrets/db_password.txt
grafana_admin:
file: ./secrets/grafana_admin.txt
volumes:
caddy_data:
caddy_config:
db-data:
prom-data:
loki-data:
networks:
app-net:
db-net:
obs-net:
DOMAIN=app.example.com
docker inspect --format 'container started: {{.State.StartedAt}}' caddy
echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null \
| openssl x509 -noout -dates
docker compose exec caddy sh -c 'ls -la /data/caddy/certificates 2>/dev/null || echo "NO PERSISTENT ACME STORAGE"'container started: 2026-08-12T03:04:11Z
notBefore=Jul 28 09:41:00 2026 GMT
notAfter=Oct 26 09:40:59 2026 GMT
drwx------ 3 root root 4096 Jul 28 09:41 acme-v02.api.letsencrypt.org-directoryIllustrative output
notBefore two weeks before the container started is the pass
condition: the certificate outlived the restart, so storage is
persistent. A notBefore within seconds of StartedAt, or the
NO PERSISTENT ACME STORAGE branch, is the failure β and it is
worth checking on any proxy you inherit rather than waiting for the
rate limit to find it for you.
Definition of Done
The capstone is done when every item below is verified with a running command and a positive result. No item is βshould beβ; each is βthis command exits 0 and shows the expected outputβ.
Two conventions make the rest of this section trustworthy. First,
every check names the offenders when it fails rather than only
saying βFAILβ β a check you cannot act on gets ignored on the second
occurrence. Second, container configuration is read from
docker inspect, never from docker compose ps: compose ps
reports runtime facts (state, health, ports, mounts) and does not
carry privilege, limits, capabilities or read-only status at all.
Functional
set -o pipefail
# Compose config validates
docker compose -f compose.yml config --quiet && echo "OK: compose valid"
# Every service running, and healthy where a healthcheck is declared.
# jq -s is required: compose ps emits newline-delimited objects, not an array.
BAD=$(docker compose ps --format json | jq -s -r '
.[] | select(.State != "running" or (.Health != "" and .Health != "healthy"))
| "\(.Service) state=\(.State) health=\(.Health)"')
[ -z "$BAD" ] && echo "OK: all services running and healthy" \
|| { echo "FAIL: not running or unhealthy:"; echo "$BAD"; }
# Every service that should have a healthcheck has one. An empty Health
# field is not a pass - it means nothing is being checked.
docker compose ps --format json | jq -s -r '
.[] | select(.Health == "") | "WARN: \(.Service) has no healthcheck"'
# Public endpoint reachable
curl -fsS https://app.example.com/health
# Expected: 200 "ok"
# API endpoint reachable
curl -fsS https://api.example.com/v1/orders
# Expected: 200 application/json
Security
set -o pipefail
CIDS=$(docker compose ps -q)
# No privileged containers
BAD=$(docker inspect $CIDS --format '{{.Name}} {{.HostConfig.Privileged}}' | grep ' true$' || true)
[ -z "$BAD" ] && echo "OK: no privileged" || { echo "FAIL: privileged:"; echo "$BAD"; }
# Capability sets, per container. cap_drop must be ALL; cap_add must be
# empty or exactly the documented exception.
docker inspect $CIDS --format \
'{{.Name}} user={{.Config.User}} drop={{json .HostConfig.CapDrop}} add={{json .HostConfig.CapAdd}}'
# Expected: drop=["ALL"] on every line; add=["NET_BIND_SERVICE"] only on caddy
# no-new-privileges on every container
BAD=$(docker inspect $CIDS --format \
'{{.Name}} {{range .HostConfig.SecurityOpt}}{{.}} {{end}}' \
| grep -v 'no-new-privileges' || true)
[ -z "$BAD" ] && echo "OK: no-new-privileges everywhere" \
|| { echo "FAIL: missing no-new-privileges:"; echo "$BAD"; }
# Nothing runs as root
BAD=$(docker inspect $CIDS --format '{{.Name}} user={{.Config.User}}' \
| grep -E 'user=$|user=0(:|$)' || true)
[ -z "$BAD" ] && echo "OK: no root containers" \
|| { echo "REVIEW: running as root (caddy is the documented exception):"; echo "$BAD"; }
# No Docker socket mounts
BAD=$(docker inspect $CIDS --format '{{.Name}} {{range .Mounts}}{{.Source}} {{end}}' \
| grep 'docker.sock' || true)
[ -z "$BAD" ] && echo "OK: no socket mounts" || { echo "FAIL: socket mounted:"; echo "$BAD"; }
# Read-only root filesystem where declared
BAD=$(docker inspect $CIDS --format '{{.Name}} ro={{.HostConfig.ReadonlyRootfs}}' \
| grep -E '/(web|api)\s.*ro=false' || true)
[ -z "$BAD" ] && echo "OK: read-only enforced on web and api" \
|| { echo "FAIL: writable rootfs:"; echo "$BAD"; }
# No secrets in env
docker compose exec -T api printenv | grep -iE 'password|secret|token' \
&& echo "FAIL: secret in env" || echo "OK: no secrets in env"
# Secrets only in /run/secrets
docker compose exec -T api ls /run/secrets/
# Expected: db_password only
# No critical CVEs
trivy image --severity CRITICAL --exit-code 1 myorg/web:1.0.0 myorg/api:1.0.0 myorg/worker:1.0.0
# Expected: exit 0, no CRITICAL findings
Networking
# SSL chain valid
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null | \
openssl x509 -noout -subject -dates -issuer
# Expected: subject CN=app.example.com; notAfter > 30 days; issuer = Let's Encrypt (or your CA)
# TLS grade
# Use https://www.ssllabs.com/ssltest/ or testssl.sh
testssl --quiet app.example.com | grep -E "Rating|Grade"
# Expected: A or A+
# External reachability from a second host
curl -fsS https://app.example.com/health
Resource controls
set -o pipefail
CIDS=$(docker compose ps -q)
# Memory and CPU limits on every container. A zero in either column is an
# unlimited container, which can take the host down on its own.
docker inspect $CIDS --format \
'{{.Name}} mem={{.HostConfig.Memory}} cpu={{.HostConfig.NanoCpus}} pids={{.HostConfig.PidsLimit}}'
BAD=$(docker inspect $CIDS --format \
'{{.Name}} mem={{.HostConfig.Memory}} cpu={{.HostConfig.NanoCpus}}' \
| grep -E 'mem=0|cpu=0' || true)
[ -z "$BAD" ] && echo "OK: every container has memory and CPU limits" \
|| { echo "FAIL: unlimited containers:"; echo "$BAD"; }
# The limits must also fit the host. Sum of memory limits vs host RAM.
TOTAL=$(docker inspect $CIDS --format '{{.HostConfig.Memory}}' | paste -sd+ | bc)
HOSTRAM=$(( $(awk '/^MemTotal:/{print $2}' /proc/meminfo) * 1024 ))
echo "sum of limits: $(numfmt --to=iec "$TOTAL") host RAM: $(numfmt --to=iec "$HOSTRAM")"
[ "$TOTAL" -lt "$HOSTRAM" ] && echo "OK: limits fit within host RAM" \
|| echo "FAIL: overcommitted - the host OOM-killer is your only limit"
# Nothing is currently being OOM-killed or CPU-throttled. These counters are
# per-cgroup and reset on restart, so read them as a trend, not a one-off.
for cid in $CIDS; do
NAME=$(docker inspect --format '{{.Name}}' "$cid")
PID=$(docker inspect --format '{{.State.Pid}}' "$cid")
CG=$(awk -F: '{print $3}' "/proc/$PID/cgroup" | head -1)
OOM=$(awk '/^oom_kill/{print $2}' "/sys/fs/cgroup$CG/memory.events" 2>/dev/null)
THR=$(awk '/^nr_throttled/{print $2}' "/sys/fs/cgroup$CG/cpu.stat" 2>/dev/null)
echo "$NAME oom_kill=$OOM nr_throttled=$THR"
done
# Expected: oom_kill=0 everywhere. A non-zero value on a container that has
# not restarted is a partial OOM - see "cgroups v2".
Observability
# Prometheus targets up
curl -s http://prometheus:9090/api/v1/targets | jq '.data.activeTargets[] | select(.health != "up")'
# Expected: empty
# Logs flowing to Loki
curl -s 'http://loki:3100/loki/api/v1/query?query={service="api"}' | jq '.data.result | length'
# Expected: > 0
# Traces emitted (sample one)
curl -s 'http://tempo:3200/api/search?tags=service.name=api&limit=1' | jq .
# Expected: at least one trace
Backup and restore
# Backup ran today
test -f /srv/backups/postgres-$(date +%F).dump && echo "OK: backup exists"
# Restore test (run quarterly; documented last successful date)
test "$(stat -c %y /var/log/restore-tests/last-success.log | cut -d' ' -f1)" && echo "OK: restore test recorded"
# RPO documented
grep -q "RPO:" runbooks/docker-runbook-disk-full.mdx && echo "OK: RPO documented"
Documentation
# Runbook for every critical scenario exists
ls docs/audits/runbooks/ 2>/dev/null || ls src/content/courses/docker/runbooks/ | wc -l
# Expected: >= 5
# Final assessment reachable
test -f dist/courses/docker/assessments/docker-final-assessment/index.html && echo "OK: assessment reachable"
Architectural boundaries
set -o pipefail
# Which network is each service actually on? Read it from the running
# containers, not from compose.yml - the file is intent, this is fact.
docker compose ps --format json | jq -s -r '.[] | "\(.Service)\t\(.Networks)"'
# The data plane must not be reachable from the edge network. Nothing except
# api and worker belongs on db-net.
BAD=$(docker compose ps --format json | jq -s -r '
.[] | select(.Networks | test("db-net"))
| select(.Service | test("^(api|worker|db)$") | not)
| "\(.Service) is on db-net"')
[ -z "$BAD" ] && echo "OK: db-net membership is minimal" \
|| { echo "FAIL: unexpected db-net members:"; echo "$BAD"; }
# The observability plane must not be published to the internet. Prometheus
# and Grafana bind loopback only; the proxy fronts them if they need access.
docker compose ps --format json | jq -s -r '
.[] | select(.Service | test("^(prometheus|grafana|loki)$"))
| "\(.Service)\t\(.Publishers // [] | map("\(.URL):\(.PublishedPort)") | join(","))"'
# Expected: 127.0.0.1 in every published address, never 0.0.0.0
A published address of 0.0.0.0 on Prometheus or Grafana is the one
line in this section that has caused real incidents: an unauthenticated
metrics endpoint and a Grafana login page, both on the public
internet, on a host whose firewall lists only 80 and 443 β because a
published port is DNATβd before the firewallβs INPUT chain ever sees
it. The lesson βDocker networking primitivesβ has the mechanism.
Acceptance
The capstone is passed when:
- Every command in the Definition of Done exits 0 with the expected output.
- The final assessment is reachable and a test learner passes.
- The DR drill (Lab 21) has been run successfully in the last 90 days.
- The restore test has been run successfully in the last 90 days.
Knowledge check
Knowledge check Β· 7 questions
Q1. In the capstone, the reverse proxy terminates:
Q2. The capstone requires which observability signals? Select all that apply.
Q3. Which three security controls are mandatory on every application container in the capstone?
Q4. A Definition of Done check reads `docker compose ps --format json | jq -e '[.[] | select(.Privileged == true)] | length == 0' && echo OK`. What does it verify?
Q5. Which of these belong in `docker inspect` rather than `docker compose ps --format json`? Select all that apply.
Q6. Mounting the reverse proxy's ACME storage directory on tmpfs is safe, because certificates are re-issued automatically when the container restarts.
Q7. The capstone is considered done when:
Passing score: 75%. Answers are checked in this browser.