Docker & ContainersXXXVIII Β· CapstoneCapstone
Capstone stage 3 β data plane, edge, and the first request
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
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.
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_adminIllustrative 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
PGDATAand then drops to thepostgresuser.cap_drop: [ALL]with nothing added makes the container fail at start with achownerror. Adding back the five it needs is the hardened position; pretending it can run with none is a container that does not start. - Redis
maxmemoryis set below the container limit. 384 MB against a 512 MB limit. Redis evicts when it reachesmaxmemory; ifmaxmemorywere 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
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 caddycompose valid
db running healthy
cache running healthyIllustrative 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
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 healthyIllustrative output
Two details in that gate are worth stealing:
docker compose ps --format jsonemits one JSON object per line, not an array.jq '.[] | ...'fails withCannot index string with string. Either address the fields directly, as the first command does, or slurp withjq -swhen you need to reason about the set, as the second does.- A service with no
healthcheckreports an emptyHealthstring, notunhealthy. A naive== "healthy"test failscaddyandworkerforever, so the filter excludes the empty case explicitly. Deciding whether that is acceptable is part of the gate β for the capstone,workershould gain a health check in stage 4, once its queue depth is observable.
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/healthstatus=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.
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -datessubject=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 GMTIllustrative 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.
- Generate the secret files with
umask 077and no trailing newline, and confirmsecrets/is ignored by git. - Start the data plane and wait for both health checks before continuing.
- Start the application services, confirming
apireports healthy β which for the capstone means its database and cache checks pass. - Start the edge and watch the ACME exchange in the Caddy logs.
- 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. - 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
Q1. Why is Redis `maxmemory` set to 384mb inside a container limited to 512M?
Q2. Postgres is configured with `cap_drop: [ALL]` and five capabilities added back. Why not drop everything?
Q3. Which negative tests belong in the stage 3 gate? Select all that apply.
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.