Skip to main content
RunBook Academy

Docker & ContainersXXXVIII Β· CapstoneCapstone

Capstone stage 3 β€” data plane, edge, and the first request

Advanced⏱ ~55 minπŸ§ͺ Lab required

What you'll learn

  • Bring the data plane up with persistent volumes and file-based secrets
  • Terminate TLS at the edge and route to the application network
  • Prove segmentation and the first end-to-end request with commands

Prerequisites

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-11

Not yet marked complete on this device.

Stage 3 is where the capstone becomes a running system. The order matters: data plane first, then the application, then the edge. Build it in the other direction and you spend the session debugging a reverse proxy that has nothing to route to.

Networks and secrets first

# compose.yml β€” the parts stage 3 adds
networks:
  edge-net:
  app-net:
  db-net:
    internal: true
  obs-net:

volumes:
  caddy-data:
  caddy-config:
  db-data:
  redis-data:

secrets:
  db_password:
    file: ./secrets/db_password
  grafana_admin:
    file: ./secrets/grafana_admin

db-net is declared internal: true, which is the single most valuable line in the file. An internal network gets no gateway and no masquerade rule, so the database cannot reach the internet and nothing outside the host can reach it β€” a stronger guarantee than a firewall rule, because there is no route to filter.

Configuration changecreate the secret files with correct permissions
mkdir -p ./secrets
umask 077
openssl rand -base64 32 | tr -d '\n' > ./secrets/db_password
openssl rand -base64 32 | tr -d '\n' > ./secrets/grafana_admin
ls -l ./secrets/
total 8
-rw------- 1 ops ops 44 Aug 11 09:02 db_password
-rw------- 1 ops ops 44 Aug 11 09:02 grafana_admin

Illustrative output

tr -d '\n' matters more than it looks. openssl rand -base64 appends a newline, and Docker passes the file byte-for-byte into /run/secrets/. Postgres reads the trailing newline as part of the password, so the value in the file and the value the application sends differ by one byte, and the resulting authentication failure looks like a wrong password rather than a formatting bug. This costs people an hour roughly once per career.

The data plane

services:
  db:
    image: postgres:16.4-bookworm
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      PGDATA: /var/lib/postgresql/data/pgdata
    secrets: [db_password]
    volumes:
      - db-data:/var/lib/postgresql/data
    networks: [db-net]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 60s
    user: "999:999"
    security_opt: [ "no-new-privileges:true" ]
    cap_drop: [ALL]
    cap_add: [CHOWN, SETGID, SETUID, DAC_OVERRIDE, FOWNER]
    deploy:
      resources:
        limits: { memory: 2G, cpus: "2.0" }
    restart: unless-stopped

  cache:
    image: redis:7.4-alpine
    command: ["redis-server", "--maxmemory", "384mb", "--maxmemory-policy", "allkeys-lru", "--save", ""]
    volumes:
      - redis-data:/data
    networks: [db-net]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    user: "999:1000"
    read_only: true
    security_opt: [ "no-new-privileges:true" ]
    cap_drop: [ALL]
    deploy:
      resources:
        limits: { memory: 512M, cpus: "0.5" }
    restart: unless-stopped

Two things here are deliberate and often got wrong:

  • Postgres keeps five capabilities. The entrypoint runs as root briefly to fix ownership on PGDATA and then drops to the postgres user. cap_drop: [ALL] with nothing added makes the container fail at start with a chown error. Adding back the five it needs is the hardened position; pretending it can run with none is a container that does not start.
  • Redis maxmemory is set below the container limit. 384 MB against a 512 MB limit. Redis evicts when it reaches maxmemory; if maxmemory were above the container limit, the OOM killer would reach the process first and Redis would be killed rather than evicting. The eviction policy is the graceful degradation, and the gap between the two numbers is what lets it happen.

The application services

  api:
    image: ${api_DIGEST}
    environment:
      DATABASE_URL: "postgres://app@db:5432/app?sslmode=disable"
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
      REDIS_URL: "redis://cache:6379/0"
      OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector:4317"
    secrets: [db_password]
    networks: [app-net, db-net]
    depends_on:
      db: { condition: service_healthy }
      cache: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "/app/api", "healthcheck"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 30s
    user: "65532:65532"
    read_only: true
    tmpfs: [ "/tmp:rw,noexec,nosuid,size=64m" ]
    security_opt: [ "no-new-privileges:true" ]
    cap_drop: [ALL]
    deploy:
      resources:
        limits: { memory: 512M, cpus: "1.0" }
    restart: unless-stopped

  worker:
    image: ${worker_DIGEST}
    environment:
      DATABASE_URL: "postgres://app@db:5432/app?sslmode=disable"
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    networks: [db-net]
    depends_on:
      db: { condition: service_healthy }
    user: "65532:65532"
    read_only: true
    tmpfs: [ "/tmp:rw,noexec,nosuid,size=64m" ]
    security_opt: [ "no-new-privileges:true" ]
    cap_drop: [ALL]
    deploy:
      replicas: 2
      resources:
        limits: { memory: 256M, cpus: "0.5" }
    restart: unless-stopped

api is on app-net and db-net; worker is on db-net only, because nothing routes HTTP to it. web is on app-net only. Each service is on the minimum set of networks its role requires, and stage 3’s gate tests that claim rather than trusting it.

The edge

# Caddyfile
{
    email ops@example.com
}

app.example.com {
    encode zstd gzip
    reverse_proxy web:8080 {
        health_uri /health
        health_interval 10s
    }
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        -Server
    }
}

api.example.com {
    encode zstd gzip
    reverse_proxy api:8080 {
        health_uri /health
        health_interval 10s
    }
}
  caddy:
    image: caddy:2.8-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    networks: [edge-net, app-net]
    depends_on:
      web: { condition: service_healthy }
      api: { condition: service_healthy }
    security_opt: [ "no-new-privileges:true" ]
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]
    deploy:
      resources:
        limits: { memory: 128M, cpus: "0.25" }
    restart: unless-stopped

caddy-data must be a named volume, not a bind mount and not ephemeral. It holds the ACME account key and the issued certificates. Losing it means re-issuing every certificate on the next start, and Let’s Encrypt rate limits will stop you doing that more than a few times a week β€” which is how a routine docker compose down -v turns into a multi-hour outage.

Bringing it up

Configuration changevalidate, then start in order
docker compose --env-file digests.env config --quiet && echo 'compose valid'
docker compose --env-file digests.env up -d db cache
docker compose ps --format '{{.Service}}\t{{.State}}\t{{.Health}}'
docker compose --env-file digests.env up -d api worker web
docker compose --env-file digests.env up -d caddy
compose valid
db	running	healthy
cache	running	healthy

Illustrative output

Bringing the stack up in three explicit steps rather than one up -d is not superstition: it means a failure names its own stage. A failure after step one is a data-plane problem, after step two an application problem, after step three an edge or DNS problem. One combined command gives you eleven containers and a wall of output.

The stage 3 gate

Read-only / Safeeverything healthy
docker compose ps --format json \
  | jq -r '"\(.Service)\t\(.State)\t\(.Health)"'
docker compose ps --format json \
  | jq -se '[.[] | select(.Health != "" and .Health != "healthy")] | length == 0' \
  && echo 'OK: all health-checked services healthy'
caddy	running	
db	running	healthy
cache	running	healthy
api	running	healthy
web	running	healthy
worker	running	
OK: all health-checked services healthy

Illustrative output

Two details in that gate are worth stealing:

  • docker compose ps --format json emits one JSON object per line, not an array. jq '.[] | ...' fails with Cannot index string with string. Either address the fields directly, as the first command does, or slurp with jq -s when you need to reason about the set, as the second does.
  • A service with no healthcheck reports an empty Health string, not unhealthy. A naive == "healthy" test fails caddy and worker forever, so the filter excludes the empty case explicitly. Deciding whether that is acceptable is part of the gate β€” for the capstone, worker should gain a health check in stage 4, once its queue depth is observable.
Read-only / Safethe first end-to-end request
curl -fsS -o /dev/null -w 'status=%{http_code} tls=%{ssl_verify_result} time=%{time_total}s\n' \
  https://app.example.com/health
curl -fsS https://api.example.com/health
status=200 tls=0 time=0.184s
{"status":"ok","db":"ok","cache":"ok"}

Illustrative output

ssl_verify_result=0 is the part that matters. A 200 over a certificate curl did not validate is not a passing edge.

Read-only / Safecertificate identity and expiry
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
subject=CN=app.example.com
issuer=C=US, O=Let's Encrypt, CN=R11
notBefore=Aug 11 08:02:11 2026 GMT
notAfter=Nov  9 08:02:10 2026 GMT

Illustrative output

The negative tests

A gate made only of things that should work proves half the design. These four must fail:

# 1. The edge must not reach the database.
docker compose exec caddy sh -c 'nc -z -w2 db 5432; echo "exit=$?"'
# Expected: exit=1

# 2. The database must not reach the internet.
docker compose exec db sh -c 'timeout 3 getent hosts example.com; echo "exit=$?"'
# Expected: non-zero β€” db-net is internal, so there is no resolver route out

# 3. The database port must not be published on the host.
ss -tlpn | grep ':5432' || echo 'OK: postgres not listening on the host'

# 4. No secret in any container environment.
docker compose ps -q | xargs -r -I{} docker inspect --format \
  '{{.Name}} {{range .Config.Env}}{{println .}}{{end}}' {} \
  | grep -iE '(password|secret|token)=..' && echo 'FAIL' || echo 'OK: no secrets in env'

Run these in the deploy pipeline, not once. Test 1 in particular reverses silently the first time somebody runs docker network connect db-net caddy to debug something.

  1. Generate the secret files with umask 077 and no trailing newline, and confirm secrets/ is ignored by git.
  2. Start the data plane and wait for both health checks before continuing.
  3. Start the application services, confirming api reports healthy β€” which for the capstone means its database and cache checks pass.
  4. Start the edge and watch the ACME exchange in the Caddy logs.
  5. Run the positive gate: all health-checked services healthy, both public endpoints returning 200 with ssl_verify_result=0, certificate expiry more than 30 days out.
  6. Run the negative gate: edge cannot reach the database, database cannot reach the internet, 5432 not published, no credentials in any container environment.

Sanity check

Knowledge check Β· 4 questions

  1. Q1. Why is Redis `maxmemory` set to 384mb inside a container limited to 512M?

  2. Q2. Postgres is configured with `cap_drop: [ALL]` and five capabilities added back. Why not drop everything?

  3. Q3. Which negative tests belong in the stage 3 gate? Select all that apply.

  4. Q4. Rotating a file-based Compose secret requires recreating every container that consumes it, not just rewriting the file.

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