Advanced Examples
Examples 33-78 cover resilience, data, and the self-host vs managed trade-off, then go deep on the
mechanics: a data-backed service with a tested backup and a restore drill, reboot resilience, multiple
services on one box, systemd resource limits, metrics, a git-driven deploy pipeline, a trade-off
writeup, a disaster rebuild, fail2ban, and staging-vs-prod promotion -- followed by an extended run
of systemd patterns (timer, socket activation, drop-in, graceful shutdown), the full proxy and TLS
surface (Caddy-vs-Nginx, HTTPS redirect, HSTS, OCSP stapling, proxy rate limiting, multi-domain TLS),
DNS CNAME/TTL/subdomain routing, env templating and EnvironmentFile, secret rotation automation and
a pre-commit secret scanner, structured logging and log shipping, health-failure alerting, a watchdog
and restart backoff, Procfile/release/scaling PaaS patterns, blue-green and connection-drain deploys,
restic and pg_dump backups with retention and verification, a cloud-init first-boot reprovision,
and a capstone preview.
Example 33: A Data-Backed Service with a Persistent Volume
ex-33 · exercises co-06, co-19
A stateless service (Example 6) loses its data on restart. A DATA-BACKED service persists to a volume that survives the process. This unit adds a data directory owned by the app, so writes live longer than the process.
learning/code/ex-33-data-backed-service/myapp-data.service:
[Unit]
Description=MyApp data-backed service (persistent volume)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/myapp
Environment=APP_DB_PATH=/opt/myapp/data/app.db # => co-19: the app writes its SQLite DB HERE
ExecStartPre=/usr/bin/install -d -o deploy -g deploy -m 750 /opt/myapp/data # => ensure the volume exists
ExecStart=/opt/myapp/venv/bin/python /opt/myapp/app.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.targetRun: install myapp-data.service /etc/systemd/system/ && systemctl daemon-reload && systemctl enable --now myapp-data
Expected: write a row, systemctl restart myapp-data, read it back -- the row survived.
Key takeaway: the process is ephemeral; /opt/myapp/data is NOT. A restart kills and revives the
process, but the DB file persists -- which is exactly what makes it the thing worth backing up.
Why it matters: state is what makes a service worth backing up (co-19) and worth recovering (Example 42). A stateless service can always be re-deployed; a stateful one's data is irreplaceable, which is why Examples 34-35 and 75-77 exist.
Example 34: A Scripted Tested Backup of the Data
ex-34 · exercises co-19
An untested backup is a wish. This script takes a consistent SQLite snapshot (safe against a running writer), stamps it, and immediately VERIFIES the copy opens -- so the backup is proven restorable at the moment it is taken.
learning/code/ex-34-backup-the-data/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
DB_PATH="/opt/myapp/data/app.db"; BACKUP_DIR="/var/backups/myapp"
install -d -m 750 "${BACKUP_DIR}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"; DEST="${BACKUP_DIR}/app-${STAMP}.db"
sqlite3 "${DB_PATH}" ".backup '${DEST}'" # => co-19: online backup API -> no torn writes
chmod 640 "${DEST}"
if ! sqlite3 "${DEST}" 'PRAGMA integrity_check;' | grep -q '^ok$'; then
echo "[FAIL] backup ${DEST} failed integrity check" >&2; exit 1
fi
echo "[verify] backup OK and integrity-checked: ${DEST}"Run: bash backup.sh
Expected:
[verify] backup OK and integrity-checked: /var/backups/myapp/app-20260730T030000Z.dbKey takeaway: testing the backup AT BACKUP TIME (the integrity_check) is the discipline that
separates a backup from a hope -- a corrupt file is caught now, not during the first real restore.
Why it matters: the part most people skip is the verification. A backup that has never been opened might be torn, truncated, or empty, and you only find out under duress. Example 35 then proves the full restore; together they close the loop.
Example 35: A Restore Drill
ex-35 · exercises co-19, co-15
The ONLY proof a backup works: actually restore from it. This script wipes the live DB, restores from the latest backup, and verifies the data the app wrote is back. The single most-skipped, highest-value operational step in the course.
learning/code/ex-35-restore-drill/restore.sh:
#!/usr/bin/env bash
set -euo pipefail
DB_PATH="/opt/myapp/data/app.db"; BACKUP_DIR="/var/backups/myapp"; UNIT="myapp-data"
LATEST="$(ls -1t "${BACKUP_DIR}"/app-*.db 2>/dev/null | head -n1)"
[ -n "${LATEST}" ] || { echo "[abort] no backup to restore from"; exit 1; }
echo "[restore] using: ${LATEST}"
systemctl stop "${UNIT}" # => no writer races the file swap
mv "${DB_PATH}" "${DB_PATH}.drill-wiped" # => set the live copy aside (reversible drill)
cp "${LATEST}" "${DB_PATH}"; chown deploy:deploy "${DB_PATH}"; chmod 640 "${DB_PATH}"
systemctl start "${UNIT}"; sleep 2
curl -fsS http://127.0.0.1:8000/health >/dev/null && echo "[verify] healthy on restored data"Run: bash restore.sh
Expected:
[restore] using: /var/backups/myapp/app-20260730T030000Z.db
[verify] healthy on restored dataKey takeaway: a backup you have never restored from is an article of faith; a drill converts it into evidence -- and it is the drill, not the backup, that proves recoverability.
Why it matters: the first time you ever restore should NOT be during an outage. A periodic drill catches a broken backup (Example 77 automates this) when the stakes are zero, so the real restore is boring instead of terrifying.
Example 36: Reboot Resilience for the Whole Stack
ex-36 · exercises co-15, co-07, co-09
The ultimate resilience test: reboot the BOX, not just the service. A correct stack brings the service, the proxy, and TLS back on its own -- no human, no pager.
learning/code/ex-36-reboot-resilience/reboot-test.sh:
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="myapp.example.com"; UNIT="myapp"
systemctl is-enabled "${UNIT}" caddy 2>/dev/null | grep -q enabled || {
echo "[abort] ${UNIT} or caddy is not 'enabled' -- it will NOT survive a reboot"; exit 1; }
echo "[pre-flight] ${UNIT} + caddy are enabled -> both return on boot"
echo "[reboot] scheduling; reconnect over SSH when the box answers (~60s)"
# shutdown -r +1 "reboot-resilience test (Example 36)"
cat <<'CHECK'
[post-reboot verify]
systemctl is-active myapp caddy # => expect: active
curl -sI https://myapp.example.com/health # => expect: HTTP/2 200
systemctl is-system-running # => expect: running (not 'degraded')
CHECK
echo "[verify] if all three pass, the stack is reboot-resilient"Run: bash reboot-test.sh (uncomment the shutdown to actually reboot)
Expected: after reconnect, is-active myapp caddy is active, the curl returns HTTP/2 200,
and is-system-running is running.
Key takeaway: reboot resilience is not a property of the service alone -- it requires the SERVICE
and the PROXY to both be enabled, or the box comes back with an app nobody can reach.
Why it matters: a reboot is the most common "planned" cause of downtime (patching, provider restarts). A stack that survives it unattended is the difference between maintenance being invisible and maintenance being an incident.
Example 37: Two Services on One Box Behind One Proxy
ex-37 · exercises co-09, co-11
The reverse proxy's superpower: one box, one :443, MANY services, each on its own subdomain. This
Caddyfile routes two subdomains to two different local ports -- one proxy, one cert set, two distinct
services.
learning/code/ex-37-multi-service-on-one-box/Caddyfile:
api.example.com { # => co-11: a distinct subdomain for service A
reverse_proxy 127.0.0.1:8000 # => service A
}
blog.example.com { # => co-11: a distinct subdomain for service B
reverse_proxy 127.0.0.1:8001 # => service B (a second app on a second local port)
}Run: install Caddyfile /etc/caddy/ && systemctl reload caddy (both subdomains' DNS must resolve
to the box)
Expected: curl https://api.example.com/health -> service A; curl https://blog.example.com/ ->
service B.
Key takeaway: the proxy multiplexes by NAME -- both services share :443 from the caller's view
but are isolated behind it, and adding a service means adding a block, not opening a new public port.
Why it matters: without a proxy, each service needs its own IP or port and its own TLS chore. The proxy collapses N services into one public face, which is why a single box can host a surprising number of small services cheaply.
Example 38: systemd Resource Limits on a Service
ex-38 · exercises co-07, co-08
An unbounded service can eat the whole box (a memory leak starves every other service). systemd
lets you CAP a service's resources so a runaway is contained before it takes the box down.
learning/code/ex-38-resource-limits/myapp-limited.service:
[Unit]
Description=MyApp with resource limits
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/venv/bin/python /opt/myapp/app.py
MemoryMax=256M # => hard ceiling: systemd KILLS the service above 256MB
MemoryHigh=200M # => soft ceiling: throttle memory reclaim above 200MB
CPUQuota=50% # => at most half of one CPU (100% = one full core)
TasksMax=64 # => cap on processes/threads (stops a fork bomb)
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.targetRun: install the unit, systemctl daemon-reload && systemctl restart myapp
Expected: systemctl show myapp -p MemoryMax -p CPUQuotaPerSecUSec prints the limits; a load
test exceeding 256M gets the service OOM-killed and restarted (visible in the journal).
Key takeaway: MemoryMax/CPUQuota/TasksMax make a runaway service kill ITSELF before it kills
the box -- containment at the supervisor level, independent of the app's own discipline.
Why it matters: on a one-service box a leak is bad; on a multi-service box (Example 37) a leak in one service takes down ALL of them. Resource limits are how you keep services as good neighbors.
Example 39: A Basic Metrics Endpoint
ex-39 · exercises co-14
Logs tell you WHY something failed; METRICS tell you THAT it is degrading before it fails. This adds a
/metrics endpoint exposing counters in the Prometheus text format -- the floor of metrics,
scrapeable by curl or any collector.
learning/code/ex-39-basic-metrics/scrape.sh:
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="myapp.example.com"; METRICS_PATH="/metrics"
RAW="$(curl -fsS "http://127.0.0.1:8000${METRICS_PATH}")" # => the raw text exposition format
echo "[metrics] local scrape:"; echo "${RAW}"
curl -fsS "https://${DOMAIN}${METRICS_PATH}" | grep -E '^myapp_' | head
sleep 1; curl -fsS "https://${DOMAIN}/" >/dev/null 2>/dev/null || true # => bump the counter
AFTER="$(curl -fsS "https://${DOMAIN}${METRICS_PATH}" | awk '/^myapp_requests_total/{print $2}')"
echo "[verify] myapp_requests_total moved -> ${AFTER}"Run: bash scrape.sh
Expected:
[metrics] local scrape:
myapp_requests_total 1234
myapp_in_flight 3
[verify] myapp_requests_total moved -> 1235Key takeaway: a metric that moves across two scrapes is the proof it is wired up -- a collector will see it grow, which is what makes alerting on a threshold possible.
Why it matters: a health check (Example 17) is binary (up/down); metrics are continuous (how busy, how slow, how full). The latter lets you catch degradation BEFORE it becomes an outage -- the difference between reactive and proactive operations.
Example 40: A Deploy Pipeline from Git
ex-40 · exercises co-16, co-18
Wire the deploy (Example 26) into a small pipeline: push -> build -> health check -> release. The health check is the gate that makes a push a SAFE deploy -- if the new release fails its check, the pipeline stops BEFORE the release.
learning/code/ex-40-deploy-pipeline-from-git/pipeline.sh:
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="myapp"; BOX_IP="$(cat .box-ip)"
echo "[1/3] build: git push" # => co-16: the push builds a new release
git push dokku main:master
echo "[2/3] health-check the new release" # => co-18 gate
STATUS="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://${BOX_IP}/health" || echo 000)"
[ "${STATUS}" = "200" ] || { echo "[gate] health-check failed (${STATUS}); rollback before release"; ssh "dokku@${BOX_IP}" ps:rollback "${APP_NAME}"; exit 1; }
echo "[3/3] release: healthy -> live"
echo "[verify] the pipeline built, health-checked, and released ${APP_NAME}"Run: bash pipeline.sh
Expected:
[1/3] build: git push
[2/3] health-check the new release
[3/3] release: healthy -> live
[verify] the pipeline built, health-checked, and released myappKey takeaway: the health check between build and release is what turns "we shipped a broken build" into "the pipeline refused to ship a broken build" -- a gate, not a hope.
Why it matters: a pipeline without a gate deploys broken code at full speed. The gate makes the deploy SAFE by proving the new release is healthy before it becomes the live one, which is the co-18 discipline applied across the whole release flow.
Example 41: Self-Hosted vs Managed Trade-Off Writeup
ex-41 · exercises co-20, co-22
Not code -- a DECISION ARTIFACT. Deploy the same app both ways and name the concrete trade-off. This file IS the deliverable co-20/co-22 ask for: a documented recommendation, not a vague "it depends."
learning/code/ex-41-self-hosted-vs-managed-writeup/decision.md (excerpt):
| Dimension | Self-hosted (this box) | Managed PaaS |
|-------------|--------------------------------|-----------------------------|
| Control | Full (the whole box is yours) | Constrained (runtime theirs)|
| Setup effort| High (Examples 1-24, by hand) | Low (one 'git push') |
| Ops burden | YOU patch/backup/wake at 3am | platform absorbs it |
| Debugability| Full substrate visibility | only the app's logs + leaks |
RECOMMENDATION (name a force, not a preference):
- Self-host when the point is to SEE the primitives (solo, learning).
- Managed when a small TEAM ships a product with no ops appetite (3am cost dominates).Run: read it; both deploys serve 200 (Examples 14 and 30) and the writeup names a winning force for each side.
Expected: the writeup names a concrete force that picks each side -- control/learning for self-host, ops-burden-absorption for managed.
Key takeaway: the trade-off is not "which is better" -- it is "which FORCE (control, cost, state, compliance, learning) is doing the deciding for THIS workload," named explicitly.
Why it matters: "we should self-host this" without naming the force is a decision waiting to become an incident. The discipline (co-22) is to state the deciding dimension and revisit it when that dimension changes.
Example 42: Disaster Rebuild from Script and Backup
ex-42 · exercises co-21, co-19
The ultimate test of reproducibility: PRETEND the box is gone, stand up a brand-new box from
Example 20's setup.sh + Example 34's backup, and confirm the service returns with its data.
learning/code/ex-42-disaster-rebuild/rebuild.sh:
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="myapp.example.com"; BACKUP_DIR="/var/backups/myapp"
echo "[1/3] running setup.sh to converge the new box to the known baseline" # => Example 20
./setup.sh
echo "[2/3] restoring data from the latest backup" # => co-19
LATEST="$(ls -1t "${BACKUP_DIR}"/app-*.db | head -n1)"
install -d -o deploy -g deploy -m 750 /opt/myapp/data
cp "${LATEST}" /opt/myapp/data/app.db && chown deploy:deploy /opt/myapp/data/app.db && chmod 640 /opt/myapp/data/app.db
systemctl restart myapp
echo "[3/3] end-to-end verify"
curl -fsS "https://${DOMAIN}/health" >/dev/null && echo "[verify] healthy on the rebuilt box"
echo "[verify] data restored: curl https://${DOMAIN}/items"Run: bash rebuild.sh (on a fresh box, DNS repointed, backup copied over)
Expected:
[1/3] running setup.sh to converge the new box to the known baseline
...
[3/3] end-to-end verify
[verify] healthy on the rebuilt box
[verify] data restored: curl https://myapp.example.com/itemsKey takeaway: this drill succeeds ONLY because Example 19 captured the setup AND Example 34 captured the data -- lose either and the rebuild is incomplete. Reproducibility is setup + backup, together.
Why it matters: a box you cannot rebuild is a single point of failure with a countdown. This example is the proof your whole self-hosting discipline composes into "lose the box, recover everything" -- the acceptance bar for the capstone.
Example 43: fail2ban Brute-Force Protection for SSH
ex-43 · exercises co-04, co-05
Key-only SSH stops password guessing, but bots still hammer port 22, polluting your logs. fail2ban
watches the auth log and TEMPORARILY BANS an IP after N failed attempts -- defense in depth layered
on the firewall.
learning/code/ex-43-firewall-and-fail2ban/jail.local:
[DEFAULT]
bantime = 1h # => how long a banned IP is blocked
findtime = 10m # => the window in which 'maxretry' failures trigger a ban
maxretry = 5 # => 5 failures within 10m -> ban (co-04)
backend = systemd # => read failures from the journal
banaction = ufw # => insert the ban as a ufw deny rule (integrates with Example 4)
[sshd]
enabled = true
port = sshRun: apt-get install -y fail2ban && install jail.local /etc/fail2ban/ && systemctl enable --now fail2ban; fail SSH 5+ times from a test IP, then fail2ban-client status sshd.
Expected: the repeat-offender IP appears under Banned IP list, and ufw status shows a deny
rule for it.
Key takeaway: fail2ban shrinks the attack surface OVER TIME -- repeat offenders lose access
automatically, so the log noise (and the small residual risk) steadily decreases.
Why it matters: even with key-only auth, unbounded SSH attempts are a log-pollution and
resource-drain nuisance. fail2ban makes "fail repeatedly, get locked out" automatic, which is the
defense-in-depth layer on top of the firewall.
Example 44: Staging vs Prod on the PaaS with Promotion
ex-44 · exercises co-16, co-18
Two environments, one PaaS: push to STAGING first, smoke-test, then PROMOTE the exact same release to PROD. Promotion reuses the already-built image, so what you tested is byte-identical to what you ship.
learning/code/ex-44-staging-vs-prod-on-paas/promote.sh:
#!/usr/bin/env bash
set -euo pipefail
PROD="myapp"; STAGING="myapp-staging"
dokku apps:create "${STAGING}" 2>/dev/null || true
echo "[1/3] push to staging: ${STAGING}"
git remote add staging "dokku@$(cat .box-ip):${STAGING}" 2>/dev/null || true
git push staging main:master
SMOKE="$(curl -s -o /dev/null -w '%{http_code}' "https://${STAGING}.example.com/health")"
[ "${SMOKE}" = "200" ] || { echo "[gate] staging smoke failed (${SMOKE}); NOT promoting"; exit 1; }
echo "[2/3] staging healthy -> promoting the exact release to ${PROD}"
dokku ps:promote "${STAGING}" 2>/dev/null || git push dokku main:master
echo "[3/3] [verify] prod now serves the promoted release: curl https://${PROD}.example.com/health"Run: bash promote.sh
Expected:
[1/3] push to staging: myapp-staging
[2/3] staging healthy -> promoting the exact release to myapp
[3/3] [verify] prod now serves the promoted release: curl https://myapp.example.com/healthKey takeaway: promotion means "tested == shipped" -- the artifact you verified in staging is the artifact prod runs, with no second build that could diverge.
Why it matters: a separate prod build that "should be the same" is a classic source of "works on my staging." Promotion eliminates that class of bug by making it literally the same release, which is co-18's safety property across environments.
Example 45: A Cost-and-Scope Recommendation
ex-45 · exercises co-20, co-22
A scoped, written recommendation -- the co-22 deliverable: NAME the force that decides self-host vs managed for a given workload, with a rule the reader can apply.
learning/code/ex-45-cost-and-scope-note/recommendation.md (excerpt):
RULE: "Self-host when the workload is STATELESS, the team is LEARNING, or the
managed-tier spend would exceed a VM plus your own on-call; otherwise stay managed."
APPLIED:
- Personal blog (stateless, solo, learning) ........... SELF-HOST (point IS to see primitives)
- SaaS with must-not-lose Postgres (stateful, HA) ..... MANAGED (managed absorbs backups+failover)
- Compliance-bound API (audit, PCI) ................... MANAGED (certified platform inherits the cert)Run: read it; the file names a concrete rule and applies it to three workloads.
Expected: each of the three workloads has a NAMED deciding force (learning / stateful-HA / compliance), not "it depends."
Key takeaway: the deliverable is a RULE with a named force, applicable to workloads beyond these three -- the opposite of "self-host when you feel like it."
Why it matters: the value of this course is not "everyone should self-host." It is "know exactly when self-hosting wins and when it does not," so a team can make the call deliberately rather than by default.
Example 46: Capstone Preview of a Fully Self-Hosted Service
ex-46 · exercises co-01–co-21
A preview of the capstone (see ./capstone/overview.md for the full four-step build): every primitive
from co-01..co-21, assembled into one runnable service on one box.
learning/code/ex-46-capstone-preview/preview.md (excerpt):
The capstone builds a small service, fully self-hosted on ONE box:
- provisioned + SSH-hardened + firewalled (co-02..co-05)
- run under systemd with restart-on-crash + boot hook (co-06..co-08, co-15)
- behind a reverse proxy with automatic TLS on a domain(co-09, co-10, co-11)
- configured via env, with secrets out-of-band only (co-12, co-13)
- with a health check, a tested backup, and a restore (co-14, co-19)
- captured as reproducible scripts (co-21)
- then deployed once more via a git-push PaaS for contrast (co-16, co-20)
ACCEPTANCE: reproduce it from scripts on a CLEAN box; reach it at HTTPS; confirm
restart-on-failure + reboot resilience + a working restore; deploy via 'git push'.Run: follow ./capstone/overview.md.
Expected: a clean-box rebuild reaches a healthy HTTPS endpoint, with restart + reboot resilience + a working restore, plus a PaaS deploy -- no committed secrets.
Key takeaway: the capstone is not a new technique -- it is every technique in this course, composed; if any one primitive is weak, the capstone exposes it.
Why it matters: this is the integration test of the whole course. Each earlier example isolates one primitive; the capstone proves they compose into a real, recoverable, reproducible service -- which is the actual thing you wanted to learn to do.
Example 47: A systemd Timer Replacing a Cron Job
ex-47 · exercises co-08
The cron line * * * * * backup.sh works, but a systemd TIMER does the same job with three
advantages: it logs to the journal, it survives a reboot cleanly, and it can run a "missed" tick after
downtime.
learning/code/ex-47-systemd-timer-vs-cron/myapp-backup.timer:
[Unit]
Description=Run the MyApp backup daily
[Timer]
OnCalendar=*-*-* 03:00:00 # => every day at 03:00 (off-peak); cron's '0 3 * * *' equivalent
RandomizedDelaySec=10m # => jitter so two timers don't fire on the same instant
Persistent=true # => if the box was off at 03:00, run the backup on next boot (cron cannot do this)
[Install]
WantedBy=timers.target
# Companion oneshot: [Service] Type=oneshot ExecStart=/opt/myapp/backup.shRun: install myapp-backup.{timer,service} && systemctl daemon-reload && systemctl enable --now myapp-backup.timer
Expected: systemctl list-timers myapp-backup.timer shows the next/last fire; failures land in
journalctl -u myapp-backup.
Key takeaway: a timer beats cron on observability (journal), environment (clean, not cron's surprises), and recovery (a missed run fires after downtime) -- for the same scheduling job.
Why it matters: a cron job's failure goes to a lost email or a silent log; a timer's failure is
in the journal next to the service it backs up. Unifying on systemd makes the whole box observable
through one lens.
Example 48: Socket Activation for Lazy Startup
ex-48 · exercises co-07
With SOCKET activation, systemd holds the listening socket and starts the service ONLY on the first
connection -- lazy startup, zero idle memory, and no dropped connection across a restart.
learning/code/ex-48-systemd-socket-activation/myapp.socket:
[Unit]
Description=MyApp socket (socket-activated)
[Socket]
ListenStream=127.0.0.1:8000 # => the local port the proxy (Example 12) forwards to
Accept=no # => one long-running service instance per socket (not one per connection)
[Install]
WantedBy=sockets.target # => the socket is created at boot; the SERVICE starts on first connectRun: install myapp.socket && systemctl start myapp.socket; the service is NOT running until
curl http://127.0.0.1:8000/health triggers it.
Expected: systemctl is-active myapp is inactive until the first connection, then active.
Key takeaway: systemd holds the socket during a restart, so no connection is refused across the
gap -- the process can die and revive without a caller ever seeing connection refused.
Why it matters: for an infrequently-used service, socket activation means the box spends zero memory on it until traffic arrives; for a frequently-restarting one, it means restarts are invisible to callers. Both are co-07 flexibility the unit file buys for free.
Example 49: A systemd Drop-In Override
ex-49 · exercises co-07
Editing a package-supplied unit file directly is fragile -- the next package update overwrites it. A DROP-IN adds to or overrides a unit without touching the original, so it survives updates.
learning/code/ex-49-systemd-drop-in-override/restart.conf (/etc/systemd/system/myapp.service.d/restart.conf):
[Service]
Restart=always # => co-07/co-15: override Example 10's 'on-failure' with 'always'
RestartSec=5 # => override the original 3s with 5s
StartLimitIntervalSec=300 # => widen Example 10's window
StartLimitBurst=10 # => more headroom than the original 5/60s
# Everything else (Description, ExecStart, User) is UNCHANGED -- the drop-in only names what it changes.Run: install -d /etc/systemd/system/myapp.service.d && install restart.conf .../restart.conf && systemctl daemon-reload && systemctl restart myapp
Expected: systemctl cat myapp | grep '^Restart=' shows always (the drop-in's value wins).
Key takeaway: a drop-in touches only the keys it names, inheriting everything else -- which is why it survives a package update that would clobber a direct edit.
Why it matters: the moment you install a service from a package, you want to customize it (restart
policy, env, limits). Drop-ins are the safe way -- your changes live in a separate file the package
manager never touches, so apt upgrade cannot silently revert them.
Example 50: Graceful Shutdown with TimeoutStopSec
ex-50 · exercises co-08
When systemd stops a service, it sends SIGTERM, waits, then SIGKILL. TimeoutStopSec tunes the
wait so the service finishes active requests before it dies -- the detail that keeps a stop from
dropping requests.
learning/code/ex-50-graceful-shutdown-timeout/graceful.conf (drop-in):
[Service]
TimeoutStopSec=15s # => co-08: 15s for the app to finish in-flight work after SIGTERM
KillSignal=SIGTERM # => the initial signal (the app should trap this and drain)
FinalKillSignal=SIGKILL # => the forceful fallback past the timeout
SendSIGKILL=yes # => confirm systemd WILL escalate (no zombie services)Run: install the drop-in, daemon-reload, then time systemctl stop myapp with a long request
in flight.
Expected: the stop waits up to 15s for the in-flight request to finish; journalctl shows a
clean Stopped, not a SIGKILL.
Key takeaway: graceful shutdown is TWO-SIDED -- systemd must send SIGTERM and wait
(TimeoutStopSec), AND the app must trap it, stop accepting NEW work, and finish IN-FLIGHT work.
Why it matters: a too-short (or missing) graceful window kills requests mid-flight at every deploy and every restart. This is the co-08 detail that, combined with Example 74's drain, makes deploys invisible to callers.
Example 51: The Same Proxy Rule in Caddy and Nginx
ex-51 · exercises co-09
Side by side: the SAME reverse-proxy rule in Caddy's and Nginx's syntax. The CONCEPT is identical; only the dialect differs -- so you can see what each tool is doing underneath the vocabulary.
learning/code/ex-51-caddy-vs-nginx-contrast/contrast.conf (excerpt):
# Caddy -------------------------------------------------
myapp.example.com {
reverse_proxy 127.0.0.1:8000 # forward + automatic TLS, redirect, renewal
}
# Nginx -------------------------------------------------
# server {
# server_name myapp.example.com; # match this host (SNI/Host)
# location / {
# proxy_pass http://127.0.0.1:8000; # forward to the app's local port
# } # (Nginx does NOT auto-do TLS; see Example 52)
# }Run: deploy either dialect; both make the app reachable at https://myapp.example.com.
Expected: both proxies terminate public traffic and forward to a local port -- same outcome, different verbosity.
Key takeaway: the reverse-proxy concept (co-09) is tool-independent; Caddy hides TLS/redirect/ headers, Nginx makes each explicit. Pick by whether you want "just works" or "every knob is mine."
Why it matters: recognizing that both tools express the SAME concept means you can transfer skills between them, and choose based on the trade-off (convenience vs. control) rather than on tribal loyalty to one syntax.
Example 52: An Nginx Reverse-Proxy Config
ex-52 · exercises co-09
The Nginx equivalent of Examples 12+14: terminate HTTPS, redirect HTTP, and proxy_pass to the app.
Unlike Caddy, Nginx does NOT obtain the cert for you -- you provision it (here via certbot).
learning/code/ex-52-nginx-reverse-proxy/myapp.conf:
server { # redirect HTTP -> HTTPS
listen 80;
server_name myapp.example.com;
return 301 https://$host$request_uri;
}
server { # HTTPS + reverse_proxy
listen 443 ssl;
server_name myapp.example.com;
ssl_certificate /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
}
}Run: install myapp.conf /etc/nginx/sites-available/ && ln -s .../sites-enabled/... && nginx -t && systemctl reload nginx
Expected: curl -sI https://myapp.example.com/health -> HTTP/2 200; nginx -t ->
syntax is ok.
Key takeaway: Nginx makes every layer explicit (the redirect, the TLS files, the upstream headers) that Caddy hides -- more work, but more control and more portability across environments that standardize on Nginx.
Why it matters: many shops and load-balancers speak Nginx config natively. Knowing it means you can operate the proxy layer in those environments, not just in Caddy-friendly ones -- a portable skill.
Example 53: Force HTTP to HTTPS at the Proxy
ex-53 · exercises co-10
A certificate is useless if callers can still reach plain HTTP and accept a silent downgrade. This Caddyfile serves ONLY HTTPS and redirects every HTTP request to its HTTPS equivalent.
learning/code/ex-53-https-redirect/Caddyfile:
http://myapp.example.com, :80 { # => plain HTTP for this host AND bare :80
redir https://{host}{uri} permanent # => 301 to the same URL over HTTPS
} # => no reverse_proxy here -- nothing is ever served over HTTP
myapp.example.com { # => the real service, over TLS only
reverse_proxy 127.0.0.1:8000
}Run: install Caddyfile /etc/caddy/ && systemctl reload caddy
Expected: curl -sI http://myapp.example.com/ -> 301 with Location: https://...;
curl -sI https://myapp.example.com/ -> HTTP/2 200.
Key takeaway: a redirect is reactive (upgrades a request that already arrived); HSTS (Example 54) is preventive (stops the browser trying HTTP at all). You want both to fully close the downgrade gap.
Why it matters: without the redirect, a typo'd http:// URL silently serves content unencrypted.
The redirect makes HTTPS the only path that ever serves the app -- a baseline co-10 guarantee.
Example 54: The Strict-Transport-Security HSTS Header
ex-54 · exercises co-10
HSTS tells browsers "always use HTTPS for this host, for N seconds, period" -- so a browser that has seen the header once REFUSES plain HTTP for that window, defeating downgrade attacks the 301 cannot fully prevent.
learning/code/ex-54-hsts-header/Caddyfile:
myapp.example.com {
reverse_proxy 127.0.0.1:8000
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# max-age=31536000 => remember "HTTPS only" for 1 year
# includeSubDomains => apply to EVERY subdomain too
# preload => opt into the browser HSTS preload list (built-in HTTPS-only)
}Run: install Caddyfile /etc/caddy/ && systemctl reload caddy, then
curl -sI https://myapp.example.com/ | grep -i strict-transport-security
Expected: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Key takeaway: preload is a one-way door -- once your domain is on the browsers' preload list,
serving HTTP becomes impossible for all users, so add it only once every subdomain is HTTPS forever.
Why it matters: HSTS is the preventive layer that the redirect (Example 53) is the reactive layer of. Together they make a downgrade attack impractical: the browser will not even attempt the HTTP request that the redirect would have had to catch.
Example 55: OCSP Stapling at the Proxy
ex-55 · exercises co-10
When a browser gets your cert, it may phone the CA (OCSP) to check for revocation -- a slow, privacy-leaking extra round trip. OCSP STAPLING makes the PROXY fetch that proof and attach it to the handshake, so the browser trusts the cert without calling the CA.
learning/code/ex-55-ocsp-stapling/ocsp.notes (excerpt):
# Caddy: OCSP stapling is ON by default -- just verify it:
echo | openssl s_client -connect myapp.example.com:443 -status 2>/dev/null \
| grep -A2 'OCSP Response Status'
# => expect: 'OCSP Response Status: successful'
# Nginx (in the server{} block):
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 valid=300s;
resolver_timeout 5s;Run: run the openssl probe against your endpoint.
Expected: OCSP Response Status: successful (the proof is stapled to the handshake).
Key takeaway: without stapling, every new client MAY make an extra call to the CA -- adding latency and leaking (to the CA) which site the client visited. Stapling moves that to the proxy, done once and reused.
Why it matters: TLS performance and privacy both improve with stapling. It is on by default in Caddy and one directive in Nginx, so the cost is low and the benefit (faster, more private TLS) is real for every caller.
Example 56: Rate Limiting at the Proxy
ex-56 · exercises co-09
A misbehaving client should not be able to hammer the app into the ground. Rate limiting at the PROXY caps requests-per-second before they reach the app -- defense at the chokepoint.
learning/code/ex-56-proxy-rate-limit/Caddyfile:
myapp.example.com {
rate_limit { # => co-09: cap requests at the proxy, not the app
zone dynamic # => one leaky-bucket per distinct key (defaults to remote IP)
events 20 # => allow a burst of 20 requests
window 1s # => ... per 1 second -> 20 r/s per client
} # => over the limit, Caddy returns 429 (Too Many Requests)
reverse_proxy 127.0.0.1:8000
}Run: install Caddyfile /etc/caddy/ && systemctl reload caddy, then a 30-request curl loop.
Expected: a mix of 200s then 429s once the burst is exceeded.
Key takeaway: capping at the proxy means the app never implements (or re-implements) rate limiting, AND the cap survives an app restart because the proxy's state is separate.
Why it matters: the proxy sees ALL traffic before the app does, so it is the natural place to enforce a per-client cap. This protects a stateless app from being resource-starved by one noisy neighbour -- a co-09 benefit beyond just forwarding.
Example 57: Multiple Domains and TLS on One Proxy
ex-57 · exercises co-10, co-11
One box, one proxy, MANY domains -- each gets its OWN cert automatically. Caddy issues one cert per site name; this Caddyfile serves two domains with two independent certificates from one process.
learning/code/ex-57-multi-domain-tls/Caddyfile:
api.example.com { # => co-11: domain A -> its own cert
reverse_proxy 127.0.0.1:8000
}
www.example.com { # => co-11: domain B -> a separate cert
reverse_proxy 127.0.0.1:8001
}
# Wildcard alternative (one cert for *.example.com, needs DNS-01):
# *.example.com { reverse_proxy 127.0.0.1:8000 }Run: install Caddyfile /etc/caddy/ && systemctl reload caddy (both domains' DNS must resolve to
the box)
Expected: openssl s_client ... -servername api.example.com | x509 -noout -subject ->
CN=api.example.com; a different cert for www.example.com.
Key takeaway: use per-domain certs for a few known names; use a wildcard (*.example.com, via
DNS-01) when you have many dynamic subdomains -- each trades setup simplicity against control.
Why it matters: serving many domains from one box is what makes a small self-hosted setup economical. The proxy (with ACME) makes adding a domain a one-block edit, not a new cert chore.
Example 58: CNAME vs A Record
ex-58 · exercises co-11
Both map a NAME to a destination, but to different KINDS of destination: A -> an IP directly; CNAME -> another NAME (an alias). This shows when each applies.
learning/code/ex-58-dns-cname-vs-a/dns-types.sh (excerpt):
# A record: name -> an IP (Example 13 used this)
dig +noall +answer myapp.example.com A # => myapp.example.com. IN A 192.0.2.10
# CNAME: name -> another name (alias to a managed host whose IP you do not control)
dig +noall +answer docs.example.com CNAME # => docs.example.com. IN CNAME myapp.hostedpaas.com.
# Rule: A when you control the box's IP; CNAME when the target is a NAME you do not control
# (a PaaS/CDN hostname) whose IP may rotate. Apex cannot be a CNAME (use ALIAS/ANAME).Run: bash dns-types.sh
Expected: the A query returns an IP; the CNAME query returns a hostname.
Key takeaway: the record TYPE encodes whether you are pointing at a stable IP you control (A) or delegating to a managed name whose IP may change (CNAME) -- choosing wrong breaks failover or wastes queries.
Why it matters: pointing a vanity domain at a PaaS (Example 30) or a CDN REQUIRES a CNAME, since the platform's IP rotates. Knowing the distinction is what makes "point my domain at the managed thing" actually work.
Example 59: DNS TTL and Propagation Verification
ex-59 · exercises co-11
TTL is how long a resolver may CACHE your record before asking again. A LONG TTL means fewer queries but slow changes; a SHORT TTL means fast changes but more DNS traffic.
learning/code/ex-59-dns-ttl-and-propagation/ttl-check.sh (excerpt):
PUBLIC_RESOLVER="1.1.1.1"
TTL="$(dig +ttlid +noall +answer @"${PUBLIC_RESOLVER}" myapp.example.com A | awk '{print $2}' | head -1)"
echo "[ttl] myapp.example.com A has TTL ${TTL}s at ${PUBLIC_RESOLVER}"
EXPECTED_IP="$(cat .box-ip 2>/dev/null || echo 192.0.2.10)"
SEEN="$(dig +short @"${PUBLIC_RESOLVER}" myapp.example.com A | tr -d '[:space:]')"
if [ "${SEEN}" = "${EXPECTED_IP}" ]; then echo "[verify] propagated: ${SEEN}";
else echo "[verify] NOT YET propagated (seen '${SEEN}'); wait up to TTL=${TTL}s"; fiRun: bash ttl-check.sh
Expected: [ttl] myapp.example.com A has TTL 3600s at 1.1.1.1; [verify] propagated: 192.0.2.10
(or the "wait" line during propagation).
Key takeaway: a DNS change is not "live" until the public resolvers return the new value, which takes up to one TTL -- so verify propagation by polling a PUBLIC resolver, not just your box.
Why it matters: setting a record and immediately testing from your box can show the OLD value for up to a TTL, fooling you into thinking the change failed. Polling a public resolver is the honest check that the world now sees what you set.
Example 60: Subdomain Routing to Multiple Services
ex-60 · exercises co-09, co-11
Extend Example 37: route by SUBDOMAIN, where each maps to a different backend behind the one proxy.
Three subdomains, three services, one :443, three automatic certs.
learning/code/ex-60-subdomain-routing/Caddyfile:
api.example.com { reverse_proxy 127.0.0.1:8000 } # the JSON API
www.example.com { reverse_proxy 127.0.0.1:8001 } # the marketing site
admin.example.com { # an internal admin UI
reverse_proxy 127.0.0.1:8002
import restrict-admin # => gate /admin to known IPs (an admin UI must not be public-open)
}
# Each subdomain needs its OWN A record (or a wildcard *.example.com) resolving to this box.Run: install Caddyfile /etc/caddy/ && systemctl reload caddy
Expected: for h in api www admin; do curl -sI https://$h.example.com/ | head -1; done -> three
status lines, one per service, all on :443.
Key takeaway: adding a service is adding a block -- no new public port, no new cert chore -- so the proxy turns "one box" into "as many distinct services as you have subdomains."
Why it matters: this is the architectural pattern behind cheap self-hosting: one box, one proxy, N services. It is also where you must remember to GATE the sensitive ones (the admin UI) with auth or an IP allowlist, since being behind the proxy does not make them private.
Example 61: Env-File Templating at Deploy Time
ex-61 · exercises co-12, co-21
A repo-committed TEMPLATE holds the SHAPE; a deploy step fills in the VALUES from a separate source
and writes the real app.env on the box -- never in the repo.
learning/code/ex-61-env-template-substitution/render.sh (excerpt):
ENV_FILE="/opt/myapp/app.env"
# app.env.tmpl (committed, safe): APP_PORT=${APP_PORT} / APP_SIGNING_SECRET=${SECRET}
APP_PORT="8000" # non-secret, environment-specific (co-12)
SECRET="$(sed -n 's/^APP_SIGNING_SECRET=//p' /opt/myapp/secrets.env)" # co-13: from the locked file
export APP_PORT SECRET
envsubst < /opt/myapp/app.env.tmpl > "${ENV_FILE}.tmp"
chmod 600 "${ENV_FILE}.tmp" && chown deploy:deploy "${ENV_FILE}.tmp" && mv -f "${ENV_FILE}.tmp" "${ENV_FILE}"
echo "[verify] rendered ${ENV_FILE}; 'git log' has never seen this file"Run: bash render.sh
Expected: /opt/myapp/app.env exists with the rendered values; git log -- /opt/myapp/app.env
shows nothing (it was never committed).
Key takeaway: templating separates the SHAPE (committed, safe) from the VALUES (per-environment, some secret), so the same template renders correctly in staging and prod with different inputs.
Why it matters: this is the bridge between "config in env" (co-12) and "reproducible setup" (co-21): the template is reproducible; the rendered file is environment-specific and never committed, satisfying both principles at once.
Example 62: The systemd EnvironmentFile Directive
ex-62 · exercises co-12
Example 15 wrote an env file; Example 61 templated one. THIS unit shows how the service actually
READS it: EnvironmentFile= loads every KEY=value line into the service's environment at start.
learning/code/ex-62-systemd-environment-file/myapp-envfile.service:
[Unit]
Description=MyApp reading config from an EnvironmentFile
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/myapp
EnvironmentFile=/opt/myapp/app.env # => co-12: loads every KEY=value into the process env
EnvironmentFile=-/opt/myapp/secrets.env # => leading '-' = optional; secrets (co-13)
ExecStart=/opt/myapp/venv/bin/python /opt/myapp/app.py # => app reads os.environ, already populated
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.targetRun: install myapp-envfile.service /etc/systemd/system/ && systemctl daemon-reload && systemctl restart myapp
Expected: systemctl show myapp -p Environment lists the merged env; the app starts reading the
file's values with no config-parsing code of its own.
Key takeaway: the leading - on the secrets EnvironmentFile makes it OPTIONAL -- the service
starts fine without it (e.g. before secrets are set), which is the safe default for a secrets file
that should not crash the unit if temporarily absent.
Why it matters: this is the cleanest way to wire co-12's env file into a systemd service: one
directive, the app sees os.environ populated, and secrets stay in their own optional, mode-0600
file -- no config-parsing code in the app at all.
Example 63: Automating Secret Rotation
ex-63 · exercises co-13, co-08
Example 21 rotated a secret BY HAND. This cron-able script automates it on a SCHEDULE, with a SAFETY property: it writes the new secret, reloads, and CONFIRMS the service came back -- rolling back if it did not.
learning/code/ex-63-secret-rotation-automation/rotate.sh (excerpt):
SECRET_PATH="/opt/myapp/secrets.env"; UNIT="myapp"
BACKUP="$(mktemp)" ; cp -a "${SECRET_PATH}" "${BACKUP}" # snapshot for rollback
trap 'rm -f "${BACKUP}"' EXIT
NEW="$(openssl rand -hex 32)"; TMP="$(mktemp)"
printf 'APP_SIGNING_SECRET=%s\n' "${NEW}" > "${TMP}"; chmod 600 "${TMP}"; chown deploy:deploy "${TMP}"
mv -f "${TMP}" "${SECRET_PATH}" # atomic install
systemctl reload "${UNIT}" 2>/dev/null || systemctl restart "${UNIT}"; sleep 3
if ! systemctl is-active --quiet "${UNIT}"; then
echo "[rotate] service did not come back; ROLLING BACK"; mv -f "${BACKUP}" "${SECRET_PATH}"; systemctl restart "${UNIT}"; exit 1
fi
echo "[verify] rotated secret + service healthy on the new value"Run: schedule via a timer (Example 47's shape); bash rotate.sh
Expected: [verify] rotated secret + service healthy on the new value; if the service fails, the
script rolls back and the OLD secret is restored.
Key takeaway: automated rotation is only safe if it is REVERSIBLE -- the snapshot + rollback means a rotation that breaks the service is undone before anyone notices, which is what makes scheduling it unattended acceptable.
Why it matters: a secret that must be rotated by hand tends to never be rotated. Automating it (with a rollback safety net) turns "rotate quarterly" from a forgotten TODO into a non-event, which is the co-13 discipline made operational.
Example 64: A Pre-Commit Hook That Blocks Secrets
ex-64 · exercises co-13
The LAST line of defense against a committed secret: a git pre-commit hook that scans the staged
diff for secret patterns and REJECTS the commit before it is ever created.
learning/code/ex-64-pre-commit-secret-scan/pre-commit (chmod +x .git/hooks/pre-commit):
#!/usr/bin/env bash
set -euo pipefail
[ -z "$(git diff --cached --diff-filter=AM -U0 --name-only)" ] && exit 0 # nothing staged
# Scan staged additions for secret-shaped patterns:
if git diff --cached -U0 | grep -nE '(sk-live-[a-z0-9]{20,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----|xoxb-[0-9]{10,})' ; then
echo "[pre-commit] BLOCKED: a secret-looking string is staged for commit." >&2; exit 1
fi
# Refuse the forbidden filenames too:
if git diff --cached --name-only | grep -qE '(^|/)(secrets\.env|\.env)$'; then
echo "[pre-commit] BLOCKED: a secret file (.env / secrets.env) is staged." >&2; exit 1
fi
exit 0Run: chmod +x .git/hooks/pre-commit, then stage a fake AKIA... string and try to commit.
Expected: git commit is REJECTED with the [pre-commit] BLOCKED message; the secret never
enters history.
Key takeaway: the hook's non-zero exit ABORTS the commit -- the secret is blocked at the earliest possible moment, before it exists in any object, not discovered later by Example 16's repo-wide scan.
Why it matters: a committed secret is permanent. A pre-commit hook is the cheapest possible insurance: it costs one script and catches the slip at the keyboard, the moment before it would become a forensic incident.
Example 65: Structured JSON Logging
ex-65 · exercises co-14
Unstructured logs are easy to read and hard to query. STRUCTURED logs (one JSON object per line) are
machine-parseable: filter, count, and alert on fields, not grep on prose.
learning/code/ex-65-structured-logging-json/Caddyfile (plus app-side shape):
myapp.example.com {
reverse_proxy 127.0.0.1:8000
log {
output file /var/log/caddy/access.log
format json # => co-14: one JSON object per request (was 'console' in Example 12)
}
}
# App side: emit one JSON line per event --
# {"ts": 1234.5, "level": "info", "msg": "request", "path": "/items", "status": 200, "ms": 12}Run: install Caddyfile /etc/caddy/ && systemctl reload caddy, then
tail -n 1 /var/log/caddy/access.log | jq .
Expected: a parsed JSON object with named fields (status, duration, etc.).
Key takeaway: a query like "count of 5xx in the last hour" is a jq filter on JSON, not a
fragile grep on free text -- and a shipper (Example 66) can index the fields directly.
Why it matters: structured logging is the precondition for every later observability step (shipping, alerting, dashboards). Without it you are string-matching; with it you are querying, which scales as the log volume grows.
Example 66: Log Shipping and Rotation
ex-66 · exercises co-14
Logs on the box are only useful while the box is up. SHIPPING them to a separate volume survives a
box loss -- which is exactly when you most need them. This rsyslog conf ships the service's journal
stream to a file under a shipped-logs volume.
learning/code/ex-66-log-shipping-and-rotation/myapp.rsyslog (excerpt):
module(load="imjournal" StateFile="imjournal.state" IgnorePreviousMessages="on")
if $programname == 'myapp' then {
action(type="omfile" file="/srv/logs/myapp.log" fileOwner="deploy" fileGroup="deploy" fileCreateMode="0640") # co-14: shipped
stop
}
# Plus logrotate on /srv/logs/myapp.log: weekly, rotate 12, compress, postrotate reload rsyslogRun: install myapp.rsyslog /etc/rsyslog.d/ && systemctl reload rsyslog
Expected: tail -n 1 /srv/logs/myapp.log mirrors the latest journalctl -u myapp -n 1 line.
Key takeaway: a shipped log survives a box rebuild (Example 42); rotation keeps it from filling the volume that survives -- together they make logs as durable as the data.
Why it matters: the box is the LEAST reliable place for the logs that explain why the box failed. Shipping them elsewhere (a volume, a remote collector) means a dead box's post-mortem is still possible, which is the whole point of keeping logs at all.
Example 67: Alert on Health-Check Failure
ex-67 · exercises co-14
Example 18 LOGGED failures; this script ALERTS on them -- after N consecutive failures, not one (a single blip is noise; N in a row is an outage).
learning/code/ex-67-alert-on-health-failure/alert.sh (excerpt):
DOMAIN="myapp.example.com"; THRESHOLD=3; STATE_FILE="/var/lib/myapp/health.state"
install -d /var/lib/myapp ; FAILS="$(cat "${STATE_FILE}" 2>/dev/null || echo 0)"
if curl -fsS --max-time 5 "https://${DOMAIN}/health" >/dev/null 2>&1; then FAILS=0; else FAILS=$((FAILS + 1)); fi
echo "${FAILS}" > "${STATE_FILE}"
if [ "${FAILS}" -ge "${THRESHOLD}" ]; then
echo "[ALERT] ${DOMAIN} unhealthy for ${FAILS} consecutive checks -- paging"
else
echo "[ok] ${FAILS}/${THRESHOLD} consecutive failures (no alert yet)"
fiRun: schedule via a timer; bash alert.sh
Expected: stop the app, wait 3 ticks -> [ALERT]; restart it -> the counter resets to [ok].
Key takeaway: alerting on a THRESHOLD (N consecutive failures) de-noises monitoring -- a single blip does not page you, but a sustained outage does, which is the signal-to-noise balance that makes on-call survivable.
Why it matters: an alert on every single failure pages you into oblivion (alert fatigue); an alert that never fires leaves outages undetected. The threshold is the tuning knob between those two failure modes.
Example 68: A systemd Watchdog for Hung Services
ex-68 · exercises co-15, co-07
Restart=always revives a CRASHED process. But a process can also HANG without exiting -- still
"running" but not answering. The systemd WATCHDOG fixes this: the service must ping systemd every
WatchdogSec, or it is force-restarted.
learning/code/ex-68-watchdog-restart/watchdog.conf (drop-in):
[Service]
WatchdogSec=30s # => co-15: report in every 30s, or presumed hung -> force restart
Restart=always
RestartSec=3
# The app MUST call sd_notify("WATCHDOG=1") more often than WatchdogSec (a background thread).Run: install the drop-in; the app pings via sdnotify; to test, deadlock the app and watch
systemd force-restart it within 30s.
Expected: systemctl show myapp -p WatchdogUSec -> 30s; after a deliberate hang, the journal
shows a watchdog timeout + restart line.
Key takeaway: a crash (exit) and a hang (no exit) are different failure modes -- Restart=always
catches the first, the WATCHDOG catches the second, and you need both for full resilience.
Why it matters: a hung service stays "active (running)" forever while serving nothing -- the
worst silent outage. The watchdog is the only systemd mechanism that detects "alive but stuck," and
it is what turns "active" from a guess into a proven claim.
Example 69: A Restart Backoff Strategy
ex-69 · exercises co-15
RestartSec=3 waits a fixed 3s between revivals. But if the service crashes BECAUSE of a transient
problem, a fixed loop hammers the same broken state. The StartLimit knobs are the escape hatch: after
N restarts, systemd stops and leaves the unit failed.
learning/code/ex-69-restart-backoff-strategy/backoff.conf (drop-in):
[Service]
RestartSec=5 # => co-15: base wait between restarts (gentler than Example 10's 3s)
StartLimitIntervalSec=120 # => over a 2-minute window ...
StartLimitBurst=8 # => ... allow at most 8 restarts; a 9th trips into 'failed'
# After the burst is exceeded, systemd leaves the unit 'failed' (does NOT keep looping) -> a loud,
# visible failure instead of a quiet resource burn.Run: install the drop-in; force 9 quick failures (e.g. point ExecStart at a missing file).
Expected: after the 9th failure, systemctl status myapp -> failed (Result: start-limit).
Key takeaway: a service that can never start MUST eventually STOP retrying -- otherwise it loops
forever, burning CPU and filling the log. StartLimitBurst is the escape hatch that makes the failure
loud.
Why it matters: an infinite crash loop is itself an outage (resource exhaustion, log flooding)
layered on the original outage. The start-limit converts that into a single, visible failed state
that an alert (Example 67) can catch and a human can act on.
Example 70: A Procfile Declaring Process Types
ex-70 · exercises co-16
A Procfile declares EVERY process type the app needs. The PaaS runs each as its own supervised set,
scaling them independently (Example 72). This declares TWO types: a web (HTTP) and a worker
(background).
learning/code/ex-70-paas-procfile/Procfile:
web: gunicorn app:app --bind 0.0.0.0:${PORT:-5000}
worker: python worker.py --queue ${QUEUE:-default}
# web => the HTTP process type; the PaaS routes inbound HTTP to THIS type
# worker => a NON-HTTP type; runs but no traffic routed to it (pulls from a queue)Run: commit the Procfile, git push dokku main:master, then dokku ps:report myapp.
Expected: the report lists BOTH web and worker, each running.
Key takeaway: a real app is rarely JUST a web server -- the Procfile names each process type so the PaaS can run and scale them INDEPENDENTLY from a single deploy.
Why it matters: separating web from worker lets you scale them to match demand independently (scale web for HTTP traffic, worker for queue depth), which Example 72 does. Without distinct process types, one deploy cannot express "more web, fewer workers."
Example 71: A PaaS Release Phase for Migrations
ex-71 · exercises co-16
A deploy that swaps new code onto a new schema BEFORE migrating breaks; so does migrating AFTER the new code is live. The RELEASE PHASE runs migrations ONCE, between build and go-live, while the OLD version still serves.
learning/code/ex-71-paas-release-phase/release.sh (excerpt):
APP_NAME="myapp"
cat > ./app.json <<'JSON'
{ "scripts": { "dokku": { "predeploy": "echo build-done", "postdeploy": "echo released" } },
"healthcheck": { "path": "/health", "timeout": 30 } }
JSON
echo "[migrate] running migrations as a release-phase one-off" # schema ready PRE-release
ssh "dokku@$(cat .box-ip)" run "${APP_NAME}" "python manage.py migrate"
git push dokku main:master # new code goes live against an ALREADY-migrated DB
echo "[verify] migrations ran before release; /health 200 across the cutover"Run: bash release.sh
Expected: migrations run (as a one-off), THEN the push releases the new code against the migrated
DB; /health stays 200 across the cutover.
Key takeaway: the release phase runs schema changes while the OLD code is still serving, so neither the old nor the new version ever runs against the wrong schema -- the no-downtime migration trick.
Why it matters: migrations are the classic deploy-breaker. Running them in the release phase (before the new code is live) is the PaaS-native fix -- it is what makes a schema change safe to ship, which is otherwise the scariest kind of deploy.
Example 72: Scaling a Process Type
ex-72 · exercises co-16
A PaaS lets you scale each process type (Example 70) to N instances INDEPENDENTLY -- web to 3 for HTTP capacity, worker to 1 if jobs are light. The PaaS load-balances across them and restarts any that die.
learning/code/ex-72-paas-scaling/scale.sh:
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="myapp"
dokku ps:scale "${APP_NAME}" web=3 # => co-16: three web instances behind the PaaS proxy
echo "[scale] web=3 (the PaaS load-balances inbound HTTP across them)"
dokku ps:scale "${APP_NAME}" worker=1 # => co-16: one worker; bump if the queue backs up
echo "[scale] worker=1 (scale up if the queue depth grows)"
dokku ps:report "${APP_NAME}" | grep -E 'Processes|web|worker' || true
echo "[verify] web x3, worker x1 -- each supervised + restarted by the PaaS"Run: bash scale.sh
Expected: ps:report shows web=3, worker=1; each instance is supervised.
Key takeaway: scaling a process type is a one-command capacity change -- the fleet-management shape the PaaS absorbs for you, which is the contrast to the single self-hosted process in Examples 7-10.
Why it matters: on the self-hosted side, "more capacity" means a bigger box or a second box with
a load-balancer. On the PaaS, it is ps:scale web=3 -- a concrete piece of operational value
(Cluster-style horizontal scaling, on one box) that the managed platform provides.
Example 73: A Blue-Green Deploy via the Proxy
ex-73 · exercises co-18
Two identical environments -- BLUE (live) and GREEN (idle). Deploy to GREEN, warm it, health-check it, then FLIP the proxy in one atomic reload. The flip is instantly reversible (reload back).
learning/code/ex-73-blue-green-deploy/Caddyfile:
myapp.example.com {
reverse_proxy 127.0.0.1:8000 # => BLUE: the currently-live instance
}
# After deploying + health-checking GREEN (127.0.0.1:8001), FLIP in one edit:
# myapp.example.com {
# reverse_proxy 127.0.0.1:8001 # => GREEN now live; BLUE becomes the idle rollback target
# }
# then: systemctl reload caddy # atomic cutover; no request sees a half-stateRun: deploy to GREEN, health-check it, edit the Caddyfile to GREEN's port, systemctl reload caddy. If GREEN is bad, revert the edit and reload back to BLUE.
Expected: a traffic loop (Example 32) sees no drops across the reload; the revert is one reload.
Key takeaway: blue-green keeps the OLD instance answering until the NEW one is PROVEN healthy, then swaps in one proxy reload -- zero gap, and an instant revert, at the cost of twice the instances.
Why it matters: this is the strongest form of co-18's zero-downtime property. Example 32 restarts the same instance (a brief gap is possible); blue-green eliminates the gap entirely by never stopping the instance callers are hitting.
Example 74: Draining In-Flight Requests Before Stop
ex-74 · exercises co-18, co-15
Before stopping a backend for a deploy (or a blue-green flip), DRAIN it: tell the proxy to stop sending NEW requests, let the IN-FLIGHT ones finish, THEN stop the process.
learning/code/ex-74-connection-drain/drain.sh (excerpt):
UNIT="myapp"; DRAIN_WAIT=20
echo "[drain] marking ${UNIT} as non-receiving; new traffic goes elsewhere" # co-18: no NEW requests
for i in $(seq 1 "${DRAIN_WAIT}"); do # poll up to DRAIN_WAIT seconds
if [ "$((i % 5))" -eq 0 ]; then echo "[drain] waiting for in-flight to finish (${i}s)"; fi
sleep 1
done
systemctl stop "${UNIT}" # co-15: a clean stop with no dropped request
echo "[verify] ${UNIT} stopped with zero in-flight requests"Run: bash drain.sh
Expected: [verify] myapp stopped with zero in-flight requests -- no caller saw an error.
Key takeaway: draining is the mechanism that makes a stop invisible -- stop accepting new work, finish the work in flight, THEN stop, so no request is killed mid-flight.
Why it matters: combined with graceful shutdown (Example 50), draining is what makes a deploy truly zero-drop. The proxy stops routing, the app finishes what it has, then exits cleanly -- the two-sided discipline a production deploy needs.
Example 75: A restic File Backup
ex-75 · exercises co-19
Example 34 backed up one SQLite file. restic deduplicates across runs -- daily snapshots of a
mostly-unchanged tree store only the changed blocks, so they are cheap. This is the "grows beyond one
file" backup.
learning/code/ex-75-backup-restic-files/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
REPO="/srv/restic/myapp"; DATA_DIR="/opt/myapp/data"
RESTIC_PASSWORD="${RESTIC_PASSWORD:?set RESTIC_PASSWORD in the env}" # => co-13: repo key, out of band
restic -r "${REPO}" snapshots >/dev/null 2>&1 || restic -r "${REPO}" init # init only if empty
restic -r "${REPO}" backup "${DATA_DIR}" --tag myapp # => co-19: one timestamped, deduped snapshot
restic -r "${REPO}" forget --keep-daily 7 --keep-weekly 4 --prune # keep 7 dailies + 4 weeklies
restic -r "${REPO}" snapshots # => lists each snapshot with a time + ID
echo "[verify] 'restic -r ${REPO} restore <id> --target /tmp/x' reproduces the tree"Run: RESTIC_PASSWORD=... bash backup.sh (schedule via a timer)
Expected: restic snapshots lists one per run; successive runs store only the changed blocks.
Key takeaway: deduplication is what makes frequent backups affordable -- a daily snapshot of a 1GB tree with 10MB of daily churn stores ~10MB, not 1GB, per run, so you can afford to keep many.
Why it matters: as the data grows beyond a single file (Example 34) to a whole tree, copying the
whole thing each run becomes wasteful. restic keeps the backup cheap AND the history deep, which is
what makes a real retention policy (Example 77) practical.
Example 76: A pg_dump Database Backup
ex-76 · exercises co-19
A SQLite DB is a FILE (copied in Example 34). A PostgreSQL DB is a RUNNING SERVER whose files must
NOT be copied directly (a live copy can be torn). pg_dump is the safe, consistent export -- the
database's own backup API.
learning/code/ex-76-backup-pgdump-database/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
DB_NAME="myapp"; DUMP_DIR="/var/backups/myapp-pg"; install -d -m 750 "${DUMP_DIR}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"; DEST="${DUMP_DIR}/${DB_NAME}-${STAMP}.sql.gz"
pg_dump -C "${DB_NAME}" | gzip > "${DEST}" # => co-19: a safe, server-consistent export
chmod 640 "${DEST}"
TMPDB="verify_${DB_NAME}_$$" # => a unique, throwaway DB name
createdb "${TMPDB}" 2>/dev/null || true
gunzip -c "${DEST}" | psql -d "${TMPDB}" -q >/dev/null 2>&1 && echo "[verify] dump reloaded OK"
dropdb "${TMPDB}" 2>/dev/null || true
echo "[verify] backup OK and reload-tested: ${DEST}"Run: bash backup.sh (schedule via a timer)
Expected: [verify] dump reloaded OK and [verify] backup OK and reload-tested: ....
Key takeaway: for a running database, the ONLY safe backup is the DB's own dump API -- a file
copy of a live DB's files can be torn (half a transaction), which pg_dump's consistent snapshot
prevents.
Why it matters: the failure mode of a file-copied live DB is the worst kind -- it often "works"
until you restore and find corrupt rows. Using pg_dump (and reload-testing it) is what makes a
database backup trustworthy instead of a time bomb.
Example 77: Backup Retention and Restore Verification
ex-77 · exercises co-19
Two things a backup discipline needs beyond "take a backup": RETENTION (delete old backups so the disk does not fill) and PERIODIC RESTORE VERIFICATION (actually restore one, so a corrupt backup is caught before you need it).
learning/code/ex-77-backup-retention-and-verify/retention.sh:
#!/usr/bin/env bash
set -euo pipefail
DUMP_DIR="/var/backups/myapp-pg"; VERIFY_DIR="/tmp/restore-verify"; KEEP_DAYS=14
echo "[retention] deleting dumps older than ${KEEP_DAYS} days:"
find "${DUMP_DIR}" -name '*.sql.gz' -mtime "+${KEEP_DAYS}" -print -delete # => co-19: bounded history
LATEST="$(ls -1t "${DUMP_DIR}"/*.sql.gz 2>/dev/null | head -n1)"
[ -n "${LATEST}" ] || { echo "[abort] no backup to verify"; exit 1; }
echo "[verify] restore-drilling the newest backup: ${LATEST}"
install -d "${VERIFY_DIR}"; gunzip -c "${LATEST}" > "${VERIFY_DIR}/restored.sql"
if grep -q 'CREATE TABLE' "${VERIFY_DIR}/restored.sql"; then echo "[verify] PASS: backup restored and contains schema";
else echo "[verify] FAIL: backup restored but no schema found -- investigate" >&2; exit 1; fi
rm -rf "${VERIFY_DIR}"Run: schedule via a timer; bash retention.sh
Expected: old dumps pruned; the latest one passes the restore + schema check.
Key takeaway: a backup that has never been restored might be silently corrupt -- periodic automated restore drills catch that before a real outage makes it catastrophic.
Why it matters: retention keeps the disk bounded; verification keeps the backups honest. Together they make "we have backups" an actual, tested guarantee rather than an assumption -- which is the only version of that statement worth anything.
Example 78: A cloud-init User-Data for First-Boot Setup
ex-78 · exercises co-21
cloud-init makes a NEW VM converge to a known state on its FIRST boot, unattended. This user-data
does what Example 20's setup.sh does, but run by the provider's first-boot agent -- so a freshly
created VM is fully provisioned before you ever SSH in. This is co-21 at its strongest.
learning/code/ex-78-cloud-init-reprovision/user-data.yaml (excerpt):
#cloud-config
packages: [python3.12, python3.12-venv, ufw, caddy, sqlite3, curl, ca-certificates]
package_update: true
users:
- name: deploy
sudo: ALL=(ALL) NOPASSWD:ALL # first-boot convenience; NARROW after setup
shell: /bin/bash
ssh_authorized_keys: [ssh-ed25519 AAAA... replace-with-your-public-key]
runcmd:
- [ ufw, --force, reset ]
- [ ufw, default, deny, incoming ]
- [ ufw, default, allow, outgoing ]
- [ ufw, allow, 22/tcp ]
- [ ufw, allow, 80/tcp ]
- [ ufw, allow, 443/tcp ]
- [ ufw, --force, enable ]
- [ install, -d, -o, deploy, -g, deploy, -m, 755, /opt/myapp ]
- [ sudo, -u, deploy, python3.12, -m, venv, /opt/myapp/venv ]
- [ systemctl, enable, --now, myapp ]
- [ systemctl, enable, --now, caddy ]Run: paste as the new VM's user-data at creation; on first SSH it already has the service running and HTTPS answering.
Expected: a fresh VM, created with this user-data, boots into the SAME baseline as Example 20's
setup.sh -- no manual SSH setup step.
Key takeaway: disaster rebuild (Example 42) becomes "create a VM with the same user-data + restore the backup" -- the box is REPRODUCIBLE from a committed file, down to its first boot.
Why it matters: this is co-21's peak. A box whose entire first-boot state is a committed file is a box you can recreate identically, anywhere, on a moment's notice -- the difference between a self-host you operate and a self-host that operates itself.
← Previous: Intermediate Examples · Next: Capstone →
Last updated July 29, 2026