Skip to main content
RunBook Academy

Docker & ContainersXXXVIII Β· CapstoneCapstone

Capstone β€” the production Docker environment

Advanced⏱ ~120 min

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

Not yet marked complete on this device.

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:
Read-only / Safeis the certificate surviving restarts, or being re-issued?
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-directory

Illustrative 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:

  1. Every command in the Definition of Done exits 0 with the expected output.
  2. The final assessment is reachable and a test learner passes.
  3. The DR drill (Lab 21) has been run successfully in the last 90 days.
  4. The restore test has been run successfully in the last 90 days.

Knowledge check

Knowledge check Β· 7 questions

  1. Q1. In the capstone, the reverse proxy terminates:

  2. Q2. The capstone requires which observability signals? Select all that apply.

  3. Q3. Which three security controls are mandatory on every application container in the capstone?

  4. 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?

  5. Q5. Which of these belong in `docker inspect` rather than `docker compose ps --format json`? Select all that apply.

  6. Q6. Mounting the reverse proxy's ACME storage directory on tmpfs is safe, because certificates are re-issued automatically when the container restarts.

  7. Q7. The capstone is considered done when:

Passing score: 75%. Answers are checked in this browser.