Files
backup-agent/docs/superpowers/specs/2026-07-25-backup-recovery-system-design.md
T
kbe d6f30c506e chore: initial import of backup scripts and design docs
Existing borg-backup.sh/dump_db.sh, old/ reference scripts, and the
brainstormed design spec for restore tooling + runbook.
2026-07-25 18:51:38 +02:00

7.7 KiB

Backup & Recovery System Design

Date: 2026-07-25

Goal

Reliable, automated backup of /home/srv/files/content (including MariaDB running in Docker) to a local Borg repo + offsite Scaleway S3 mirror, plus a documented, scriptable recovery path so downtime is short and predictable.

Current State

  • old/borg.sh, old/maria.sh — scripts currently live on the server.
  • borg-backup.sh, dump_db.sh (repo root) — improved replacements, not yet deployed. Add: encrypted repo (repokey-blake2 via BORG_PASSCOMMAND), flock-based locking, preflight checks, container health polling, atomic dump staging/swap, non-transactional table warnings, integrity checks (borg check, daily archive-only / weekly --verify-data), retry/timeout wrapping, log rotation.
  • No restore tooling and no scheduling exist yet.

dump_db.sh is kept as-is. borg-backup.sh had one functional change made during this design: it no longer stops the MariaDB container. dump_db.sh already produces a transactionally-consistent dump via --single-transaction, so a live logical dump is safe without stopping anything (this only covers InnoDB tables — the script already warns if it finds non-InnoDB tables). The remaining risk was borg also archiving MariaDB's raw on-disk data directory while it's being written to; that's resolved by excluding it from the archive entirely (drop a .nobackup marker file in it — borg-backup.sh already passes --exclude-if-present .nobackup), so only the logical dump ever gets backed up, and restore only ever needs the dump anyway. Net effect: daily backups run with zero MariaDB downtime. This exclusion step needs to be added to the one-time setup, documented in the runbook.

Architecture

cron (daily) → borg-backup.sh
                 ├─ dump_db.sh (mysqldump per-DB, atomic staging swap;
                 │              container stays up throughout)
                 ├─ borg create (encrypted, zstd) → local repo
                 │              (raw MariaDB data dir excluded via .nobackup;
                 │               only the logical dump is archived)
                 ├─ borg prune + compact
                 ├─ borg check (daily fast / weekly full --verify-data)
                 └─ rclone sync → Scaleway S3 (offsite mirror)

restore.sh (manual, run on demand during an incident)
  mode=full   : fresh/broken server → extract full content dir from archive
                → restore all DBs → start container
  mode=db     : single database → restore one .sql from a chosen archive
  mode=file   : single file/dir → borg extract path, no DB involved

Components

All at repo root, deployed to the server alongside the existing scripts.

borg-backup.sh (existing, one change: no longer stops MariaDB)

Orchestrates the daily backup: dump → archive → prune → compact → check → offsite sync. The container is never stopped — the dump step alone produces a consistent backup, and the raw data directory is excluded from the archive. Already has locking, timeouts, health-check pings, and cleanup-on-exit logic (cleanup now only intervenes if the container happens to be down for an unrelated reason, restarting it defensively).

dump_db.sh (existing, unchanged)

Runs inside borg-backup.sh step 1. Dumps each non-system database plus users/grants to a staging dir, verifies each dump file, then atomically swaps staging into place. Deployed at /home/srv/files/content/mariadb/dump_db.sh.

restore.sh (new)

restore.sh full  [--archive NAME] [--force]
restore.sh db    <db_name> [--archive NAME]
restore.sh file  <path-within-target> [--archive NAME] [--dest DIR]
restore.sh --dry-run <above args>
restore.sh --list-archives
  • Defaults --archive to the most recent archive in the repo.
  • full: refuses to run if $TARGET is non-empty unless --force is passed (protects against overwriting a working system by accident). Extracts the full archive over $TARGET, then restores every .sql dump found under mariadb/dump/ using root DB credentials, then starts the mariadb container and waits for it to become healthy.
  • db <name>: extracts only mariadb/dump/<name>.sql from the archive, prompts for the database name to be typed again as confirmation (it's destructive — drops/recreates the DB), restores it with root credentials.
  • file <path>: borg extract of a single path from the archive into --dest (default: a scratch dir under /tmp), no DB or container involvement — safe, non-destructive.
  • --dry-run: prints exactly what would run (archive chosen, paths extracted, commands) without touching anything.
  • Same logging conventions as borg-backup.sh (/var/log/borg/restore-*.log), non-zero exit on any failure, no partial/silent restores — a failed step aborts before touching the "good" copy where possible.
  • Uses the same BORG_PASSCOMMAND / passphrase file as backups.
  • Restore DB operations authenticate as root via MYSQL_ROOT_PASSWORD or a root password file (mirrors how dump_db.sh resolves credentials), since restore needs full privileges (CREATE/DROP) that the limited backup user used for dumping does not have.

RUNBOOK.md (new)

Single operational document covering:

  1. One-time setup (run once, by hand, not scripted): borg init --encryption=repokey-blake2, create /root/.borg-passphrase (chmod 600), create the backup MariaDB user with the grants dump_db.sh needs (SELECT, LOCK TABLES, SHOW VIEW, TRIGGER, PROCESS, RELOAD) and its password file /root/.mariadb-backup.pw, drop a .nobackup marker file into MariaDB's Docker volume data directory (excludes raw DB files from the archive — only the logical dump under mariadb/dump/ gets backed up), configure rclone for the scaleway remote, set the real healthcheck URL.
  2. Deploying the scripts: where each file goes, permissions, chmod +x.
  3. Scheduling: cron entry (daily, off-peak hours) invoking borg-backup.sh, plus how to check the last run (/var/log/borg/, healthcheck dashboard).
  4. Day-2 operations: listing archives, checking repo size/health, rotating the passphrase, what to do if the lockfile is stale.
  5. Recovery procedures — one clearly-numbered walkthrough per scenario (full disaster, single DB, single file), written so someone unfamiliar with the internals can follow it step-by-step under pressure, including exact restore.sh invocations and what to verify afterward (container healthy, app responds, row counts sane).
  6. Restore drill cadence: recommend a quarterly test restore into a scratch location to confirm backups are actually usable.

Error Handling & Safety

  • restore.sh never overwrites live data without an explicit --force (full) or typed confirmation (db).
  • All destructive steps are logged before execution.
  • --dry-run available for every mode.
  • Failures abort immediately (set -euo pipefail), matching the existing scripts' style; partial state is called out explicitly in the log.

Testing

No automated test framework — these are ops scripts against real infrastructure. Verification consists of:

  • restore.sh --dry-run runs against the real repo to sanity-check archive selection and command construction.
  • Quarterly real restore drill into a scratch directory (documented in the runbook), confirming dumps are complete and importable.

Out of Scope

  • No changes to dump_db.sh, and no changes to borg-backup.sh beyond removing the DB stop/start (see Components above).
  • No monitoring/alerting beyond the existing healthcheck ping.
  • No multi-server / multi-target generalization — this is specific to /home/srv/files/content.