Docker & ContainersXXXVIII Β· CapstoneCapstone
Capstone stage 5 β the DR drill and the go-live sign-off
What you'll learn
- Back up the capstone stack in a form that has been proven to restore
- Rehearse four failure modes and measure the actual RTO and RPO of each
- Assemble the evidence pack that makes the capstone sign-off auditable
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
The stack runs, it is hardened, and it is observed. The remaining question is the only one the capstone is really about: what happens when it breaks, and can you prove your answer?
Stage 5 rehearses four failures, measures the recovery of each, and assembles the evidence pack. Nothing here is optional β a capstone that has never been broken on purpose is a capstone whose recovery properties are unknown.
What must survive, and what may not
Decide this explicitly before writing a backup script, because it determines the whole design:
| Data | Verdict | Reason |
|---|---|---|
db-data (Postgres) | Must survive | The system of record |
caddy-data | Should survive | ACME account key and certificates; rate limits punish re-issuance |
redis-data | May be lost | A cache; rebuilt from the database |
prom-data | May be lost | Operational history; painful, not fatal |
loki-data | Should survive to retention | Often the only forensic record of an incident |
| Secrets | Must survive, separately | Restoring them from the same medium as the data defeats the separation |
The backup
#!/usr/bin/env bash
# capstone-backup.sh β run from a systemd timer, nightly.
set -euo pipefail
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
DEST=/srv/backups
mkdir -p "$DEST"
# 1. Postgres, logical, consistent, compressed. Custom format so pg_restore
# can do a selective restore.
docker compose exec -T db \
pg_dump -U app -d app --format=custom --compress=9 \
> "$DEST/db-$STAMP.dump"
# 2. Caddy's certificate and account state, from the volume, without
# stopping the container.
docker run --rm \
-v capstone_caddy-data:/data:ro \
-v "$DEST":/backup \
--user 0:0 \
alpine:3.20 tar -czf "/backup/caddy-$STAMP.tar.gz" -C /data .
# 3. The configuration that reproduces the stack.
tar -czf "$DEST/config-$STAMP.tar.gz" \
compose.yml Caddyfile prometheus.yml rules.yml digests.env
# 4. Integrity manifest, so a corrupted transfer is detectable.
( cd "$DEST" && sha256sum ./*-"$STAMP".* > "manifest-$STAMP.sha256" )
# 5. Off-host, to storage this host cannot delete from.
rclone copy "$DEST" "backup-remote:capstone/$STAMP" --immutable
find "$DEST" -name '*.dump' -mtime +14 -delete
Five properties make that a backup rather than a copy:
--format=customallowspg_restoreto restore a single table, which is what most real recoveries need.- The config tarball includes
digests.env, so the rebuild uses the same image digests that were running rather than whatever the tags point at on the day of the incident. - The manifest turns a silently truncated transfer into a failed verification.
--immutableon the remote, plus credentials that cannot delete, is what makes the backup survive a compromise of this host.- Local retention is short and remote retention is long, because local copies exist for speed and remote copies exist for safety.
LATEST=$(ls -1t /srv/backups/db-*.dump | head -1)
sha256sum -c --ignore-missing /srv/backups/manifest-*.sha256 2>/dev/null | tail -3
pg_restore --list "$LATEST" | wc -l/srv/backups/db-20260811T020000Z.dump: OK
412Illustrative output
Drill 1 β daemon restart
Tests the live-restore decision from stage 1.
# Start a request loop from a second machine, then:
sudo systemctl restart docker
Expected: the request loop shows no failures. docker ps is briefly
unavailable, health check states freeze and then resume, and no
container restarts.
Record: the number of failed requests (target: 0) and the daemon downtime.
Drill 2 β container loss
docker compose kill -s SIGKILL api
Expected: restart: unless-stopped brings it back within seconds;
Caddyβs health checks route around it in the meantime; the burn-rate
alert from stage 4 does not fire for a single short outage.
Record: time from kill to healthy, and whether any request failed.
That last point is the interesting one. If requests failed, the edge
was routing to an instance the health check had not yet marked down β
the gap between interval x retries and reality. It is fixable with a
shorter interval or with connection draining, and it is worth knowing
the number.
Drill 3 β database restore
The one people skip and the one that matters.
# 1. Note the current state so you can prove the restore worked.
docker compose exec -T db psql -U app -d app -c \
"SELECT count(*) FROM orders;"
# 2. Restore the latest dump into a scratch database on the same server.
LATEST=$(ls -1t /srv/backups/db-*.dump | head -1)
docker compose exec -T db createdb -U app restore_test
docker compose exec -T db pg_restore -U app -d restore_test --no-owner \
< "$LATEST"
# 3. Compare.
docker compose exec -T db psql -U app -d restore_test -c \
"SELECT count(*) FROM orders;"
# 4. Clean up and record the result.
docker compose exec -T db dropdb -U app restore_test
date -u +%FT%TZ | sudo tee /var/log/restore-tests/last-success
Restoring into a scratch database rather than over the live one is what makes this drill runnable on a schedule. A restore procedure that requires an outage gets run once, at go-live, and never again.
Record: restore duration (this is your RTO contribution) and the row delta between live and restored (this is your RPO, in rows rather than in minutes, which is the more useful unit).
Drill 4 β total host loss
The full rebuild, on a fresh machine, timed from nothing.
- Provision a host to the stage 1 specification: filesystems, daemon configuration, time sync, firewall.
- Retrieve the config tarball and the latest dump from the off-host copy, and verify the manifest before using either.
- Retrieve the secrets from the credential store β a separate system, with its own access path.
- **Restore
caddy-data** into the volume before the first start, so certificates are not re-issued and rate limits are not consumed. - Bring up the data plane, restore the database dump into it, and confirm the row counts.
- Bring up the application and edge using the digests from
digests.env, not the tags. - Repoint DNS and record the time at which the first request succeeded through the public name.
- Run the stage 3 gate in full, positive and negative, on the rebuilt host.
Record the wall-clock time for each step. The total is your real RTO, and it is invariably longer than the estimate β the DNS TTL alone is often the largest single term, and it is the one nobody includes.
cat /srv/runbooks/drill-log.tsvdate drill target measured pass
2026-08-11 daemon-restart 0 failed req 0 failed req yes
2026-08-11 container-kill RTO 30s RTO 11s yes
2026-08-11 db-restore RTO 15m RTO 6m20s yes
2026-08-11 db-restore RPO 24h RPO 15h yes
2026-08-11 host-rebuild RTO 4h RTO 3h05m yesIllustrative output
The evidence pack
The capstone overview defined the Definition of Done. Stage 5 collects the evidence for it. Two notes on collecting it correctly:
docker compose ps -q | while read -r c; do
docker inspect --format \
'{{.Name}} priv={{.HostConfig.Privileged}} user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}} mem={{.HostConfig.Memory}} cpu={{.HostConfig.NanoCpus}} caps={{json .HostConfig.CapDrop}}' "$c"
done/capstone-caddy-1 priv=false user= ro=false mem=134217728 cpu=250000000 caps=["ALL"]
/capstone-api-1 priv=false user=65532:65532 ro=true mem=536870912 cpu=1000000000 caps=["ALL"]
/capstone-db-1 priv=false user=999:999 ro=false mem=2147483648 cpu=2000000000 caps=["ALL"]Illustrative output
docker compose ps -q | while read -r c; do
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.Name}}' "$c"
done | awk '$1 == 0 || $2 == 0 {print "UNBUDGETED:", $3}'
echo 'budget audit complete'budget audit completeIllustrative output
The full pack, one file per section, dated and committed:
| Section | Evidence |
|---|---|
| Functional | Stage 3 positive gate transcript; both endpoints 200 with ssl_verify_result=0 |
| Segmentation | Stage 3 negative gate transcript, all four tests failing as required |
| Supply chain | digests.env; cosign verify output per image; scan report per digest |
| Hardening | Container audit script output with zero FAIL lines, plus the accepted-risk register |
| Capacity | Sum of limits against host RAM and cores; the growth slope and threshold date |
| Observability | All targets up; correlated trace ID across three pillars; recorded alert fire and clear |
| Recovery | The drill log above, dated within 90 days |
| Ownership | Named owner, on-call rotation, and the runbook location |
Sanity check
Knowledge check Β· 4 questions
Q1. The measured row delta in a restore drill is unacceptable. What is the correct response?
Q2. Why is `./secrets/` deliberately excluded from the capstone config backup?
Q3. Which fields does `docker compose ps --format json` actually provide? Select all that apply.
Q4. Restoring the database dump into a scratch database rather than over the live one is what makes the restore drill repeatable on a schedule.
Passing score: 75%. Answers are checked in this browser.