personal_infra/ansible/roles/backup_source
counterweight c2de6dbbd9
backups: monitor both the dump and the pull, per source
Twelve endpoints in two groups, because they answer different questions and
fail for different reasons:

  backup-dump_<svc>       pushed by the SOURCE right after its dump runs
  backup-store_<svc>      pushed by the BOX at 05:30, per source
  backup-store_pull-job   pushed by the BOX, about the box itself

The store alone could catch almost everything, because the artefact filename
carries the source's dump timestamp - a source whose timer died still pulls
"ok" forever, but the timestamp gives it away. What the source side adds is
LATENCY and DIAGNOSIS: the store only learns at the next 04:00 pull, and it
cannot tell you whether the dump broke or the pull did.

pull-job is separate from the per-source checks because it is a LEADING
indicator where those are lagging ones. A disabled pull timer, a failed pull
job, or a filling disk are all visible immediately, while the per-source checks
only fire once an artefact is >26h stale - about a day later. Disable the timer
at 10:00 and every source stays green until tomorrow; pull-job goes red this
morning and names the cause instead of showing six stale sources.

Frequencies: dumps 02:00-02:30 staggered, pull 04:00, verification 05:30, all
daily and Persistent. 26h staleness decides red; a 30h Gatus heartbeat catches
the verification itself having stopped, so a dead check-backups.timer cannot
hide a stale backup.

arbret has no dump endpoint: prd-arbret is in [arbret], which `managed`
deliberately excludes, so nothing of ours runs there. Store-checked only.

check-backups.sh was manual-only; it now runs on a timer and reports per source
rather than only printing. The human-readable report is unchanged.

A SERIOUS bug introduced and fixed in this change, recorded because the shape
is easy to repeat: the reporting hook was added to backup.sh as a second
`trap ... EXIT`. Bash REPLACES the EXIT handler rather than adding to it, so
that silently deleted the trap which restarts the stopped service - the one the
script's own comment calls "the point", and the bug the role was written to
eliminate. Every backup then stopped its service and left it stopped. It took
forgejo, lnbits, headscale and memos down for several minutes each, and nothing
caught it: the dumps exit 0, the artefacts are correct, the deploy reports
failed=0, and liveness only proves the HOST is up. There is now ONE EXIT
handler doing both jobs, armed BEFORE the stop so a failure during the stop
still restarts. Verified by rendering both variants, asserting exactly one EXIT
trap in each, and simulating a mid-way failure to confirm the restart fires.

Verified: all 12 endpoints UP; six sources pulled cleanly (arbret 31M,
headscale 198K, memos 7.8M, vaultwarden 2.1M, lnbits 30M, forgejo 2.6G), store
16% full, "RESULT: all checks passed".

Known gaps, deliberately not closed here:
  * `yell` warnings - disk 75-90%, an artefact under half the previous size,
    retention not pruning - never reach Gatus, because a push is binary.
  * Nothing verifies a backed-up service came back UP. That is the gap that let
    the trap bug run unnoticed, and it is what the next change addresses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:22:12 +02:00
..
defaults backups: monitor both the dump and the pull, per source 2026-09-14 09:22:12 +02:00
handlers backup stuff 2026-09-12 16:02:00 +02:00
tasks backups: monitor both the dump and the pull, per source 2026-09-14 09:22:12 +02:00
templates backups: monitor both the dump and the pull, per source 2026-09-14 09:22:12 +02:00
README.md age backups everywhere 2026-09-12 16:20:42 +02:00

backup_source

Makes a host back itself up: dump to stdout, encrypt with age, write to a local directory, prune, on a systemd timer. small-backups-box pulls the directory later (see backup_store).

Modelled on prd-arbret, which has been doing exactly this correctly since before the rest of the estate was migrated.

Usage

- ansible.builtin.include_role:
    name: backup_source
  vars:
    backup_source_name: headscale
    backup_source_description: "Headscale"
    backup_source_dump_command: "tar -czf - -C / var/lib/headscale etc/headscale"
    backup_source_stop_service: headscale
    backup_source_retention_days: 7

Produces /opt/backups/headscale/headscale_<YYYYmmdd_HHMMSS>.tar.gz.age, headscale-backup.{service,timer}, and /usr/local/bin/headscale-backup.sh.

Why the source encrypts, not the destination

age -r <recipient> is asymmetric and the host holds only the public key, so a compromised host cannot read its own backups — or anyone else's. The scripts this replaces encrypted with GPG on the laptop, after the data had already crossed the network, which protects the artefact at rest but not in transit.

The matching identity lives only on lapy and is escrowed. Lose it and every artefact everywhere becomes noise, including arbret's.

backup_source_dump_command writes to STDOUT

The role pipes it into age, so plaintext never touches the disk. Use -C / with relative paths in tar rather than absolute ones: it avoids tar's "removing leading /" and makes the restore target explicit.

Services that are not systemd

backup_source_stop_service runs systemctl stop/start. For anything else, give the pair explicitly — vaultwarden is a docker compose stack, so systemctl stop vaultwarden silently does nothing:

backup_source_stop_command:  "docker compose -f /opt/vaultwarden/docker-compose.yml stop"
backup_source_start_command: "docker compose -f /opt/vaultwarden/docker-compose.yml start"

The same EXIT trap wraps both forms. The assert refuses a stop command without a matching start command, because that combination fails in the one way you would not notice: the service stops and never comes back.

More than one thing to back up

tar takes several paths, so multiple files or directories are normally one artefact — headscale captures /var/lib/headscale and /etc/headscale together, lnbits captures its data directory and its .env.

Prefer one artefact. A backup should be a consistent snapshot, and two artefacts written by two runs can drift — you can end up restoring an .env that does not match the database it configures. Pulling a single file back out needs no unpacking:

age -d -i <identity> <artefact> | tar -xzO opt/lnbits/lnbits/.env

If you genuinely need separate artefacts, call the role twice with different backup_source_names rather than extending it — but only one call may set backup_source_stop_service, or the service is stopped twice per night.

The case this shape cannot express is a database dump plus a file tree (pg_dump and a media directory, say): you cannot merge those into one stream without staging plaintext on disk, which is exactly what this design avoids. None of the current services need it — all are file trees, all stopped for the dump. A future one that does should use two role calls.

Everything here is sqlite, so everything stops

All five services are sqlite-backed, several in WAL mode (-wal/-shm files present). A live copy of a WAL-mode database can be torn or stale, so each is stopped for the duration. Measured downtime: under a second for headscale and memos, ~6 s vaultwarden, ~11 s lnbits, and 2m36s for forgejo — 2.7 G of repos and database. That last one is the real cost of a consistent snapshot; if it becomes unacceptable the answer is sqlite3 .backup plus an online repo copy, not skipping the stop.

The trap is the reason this role exists

When backup_source_stop_service is set, the script stops the unit and installs an EXIT trap that starts it again. Without it, a failed dump leaves the service down until the next timer fires — every hand-written script this replaced had that bug, and it was only ever masked because their systemctl stop failed first, before anything was stopped.

Verified on spacey: with the dump forced to fail, the log shows Stopping → Writing → Restarting, the script exits 1 (so systemd marks the unit failed rather than hiding it), and headscale is active afterwards.

If systemctl stop itself fails, set -e exits before the trap is installed — which is correct, because nothing was stopped.

.partial

The dump writes <artifact>.partial and only mvs it into place on success, so a truncated file is never mistaken for a backup. A failure inside the pipeline does leave one behind, and the prune glob cannot match it (it ends .partial, not .tar.gz.age), so the script clears stale partials at the start of each run. Tested by failing mid-pipeline: 1 partial left, 0 after the next run.

backup_source_stop_service may be a bare name

headscale and headscale.service both work. The unit template normalises it, because systemd rejects a bare name in After= with Failed to add dependency ... Invalid argument — which it logs and then ignores, so the unit appears to work while carrying no ordering at all.

Retention is two-tier

backup_source_retention_days is local and short — these hosts are disk-constrained. The long tail lives on small-backups-box, which decides its own retention per source. Losing the local copy is expected and fine.