Files
backup-agent/RUNBOOK.md
kevin.batailleandClaude Sonnet 4.6 f3cf24a41c docs: update README and RUNBOOK for multi-deployment config
- README: remove hardcoded srv paths; add configs/ to components table;
  document BACKUP_CONF usage for multi-server setups; note DB_CONTAINER=
  makes docker optional
- RUNBOOK: generalize §1 setup to use config variables (source config,
  use $REPO / $BORG_PASSPHRASE_FILE / etc. instead of hardcoded paths);
  §4 day-2 ops now shows source-config pattern before direct borg commands;
  §5 recovery notes BACKUP_CONF for restore.sh; §6 drill uses config vars

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3rQSEidP6Y61kaCtxjVV1
2026-08-30 23:50:38 +01:00

8.4 KiB

Backup & Recovery Runbook

Covers borg-backup.sh (daily backup), dump_db.sh (MariaDB logical dumps, invoked by the backup script), and restore.sh (recovery).

All deployment-specific values (TARGET, REPO, DB_CONTAINER, etc.) live in a config file under configs/. Both scripts source it automatically — see §2 for how to point them at the right one.


1. One-Time Setup (per server)

Run once, by hand, on the server. Substitute values from your config file (configs/<deployment>.conf). The examples below use shell variables sourced from it:

source /opt/backup-agent/configs/nexusvoice.conf   # adapt path

1.1 Initialize the Borg repo

mkdir -p "$(dirname "$REPO")"
borg init --encryption=repokey-blake2 "$REPO"
# If running unencrypted by deliberate choice:
# borg init --encryption=none "$REPO"

1.2 Passphrase file (backup and restore both read it)

echo 'your-strong-passphrase' > "$BORG_PASSPHRASE_FILE"
chmod 600 "$BORG_PASSPHRASE_FILE"

Skip this step only if running without encryption and without a passphrase.

1.3 MariaDB backup user (used by dump_db.sh)

Read-only; --single-transaction makes the dump consistent without stopping the container.

CREATE USER 'backup'@'%' IDENTIFIED BY 'a-strong-password';
GRANT SELECT, LOCK TABLES, SHOW VIEW, TRIGGER, PROCESS, RELOAD ON *.* TO 'backup'@'%';
echo 'a-strong-password' > /root/.mariadb-backup.pw
chmod 600 /root/.mariadb-backup.pw

1.4 MariaDB root password file (used only by restore.sh)

Restore needs CREATE/DROP privileges the backup user does not have.

echo 'the-mariadb-root-password' > "$ROOT_PASSWORD_FILE"
chmod 600 "$ROOT_PASSWORD_FILE"

1.5 Exclude the MariaDB raw data directory

Find the host directory bind-mounted into the container as its datadir and drop a marker file in it. Borg skips any directory that contains .nobackup (via --exclude-if-present), so live InnoDB files are never read:

touch "${TARGET}/mariadb/data/.nobackup"

This is what allows backups to run with the container up: only the logical dump under ${DUMP_SUBDIR}/ is ever archived or restored.

1.6 Configure the Scaleway rclone remote

rclone config
# Create a remote named "scaleway", type S3, with your Scaleway
# Object Storage credentials and region.

2. Deploying the Scripts

Single deployment

Copy borg-backup.sh, dump_db.sh, restore.sh, and the configs/ directory to /opt/backup-agent/. Symlink the relevant config as backup.conf beside the scripts, or pass BACKUP_CONF explicitly in the cron entry.

chmod +x /opt/backup-agent/borg-backup.sh \
         /opt/backup-agent/restore.sh \
         /opt/backup-agent/dump_db.sh

# Option A — symlink (scripts auto-discover backup.conf beside them):
ln -s /opt/backup-agent/configs/srv.conf /opt/backup-agent/backup.conf

# Option B — explicit BACKUP_CONF in the cron entry (see §3).

Multiple deployments on different servers

Each server gets its own config. Deploy the same three scripts to /opt/backup-agent/ on each server; point each cron entry at its config via BACKUP_CONF:

# /etc/cron.d/borg-backup  (nexusvoice server)
BACKUP_CONF=/opt/backup-agent/configs/nexusvoice.conf
30 2 * * * root /opt/backup-agent/borg-backup.sh >> /var/log/borg/cron.log 2>&1

Before the first run, verify configs/nexusvoice.conf:

  • TARGET — current path; update if files move (e.g. /home/acid/nexusvoice/opt/nexusvoice)
  • DB_CONTAINER — confirm the MariaDB container name on that server
  • REPO — must not overlap with any other deployment's repo

3. Scheduling

Add a cron entry to run the backup daily, off-peak:

# /etc/cron.d/borg-backup  (srv — default config via symlink)
30 2 * * * root /opt/backup-agent/borg-backup.sh >> /var/log/borg/cron.log 2>&1

Check the last run:

ls -lt /var/log/borg/backup-*.log | head -1   # latest log file
tail -50 /var/log/borg/backup-*.log           # inspect it

4. Day-2 Operations

Source your config first to get the right BORG_REPO, BORG_PASSCOMMAND, etc.:

source /opt/backup-agent/configs/nexusvoice.conf   # adapt
export BORG_REPO="$REPO"
export BORG_PASSCOMMAND="cat $BORG_PASSPHRASE_FILE"

Then standard borg commands work without extra flags:

# List archives
./restore.sh --list-archives
# or directly:
borg list

# Repo size and health
borg info

# Rotate the passphrase
# Note: only re-encrypts the key, not the archive data; old passphrase
# still needed for archives created before the change until fully migrated.
borg key change-passphrase

# Break a stale lockfile (backup or restore aborted mid-run)
borg break-lock

5. Recovery Procedures

All restore.sh commands accept:

  • --dry-run — preview exactly what would happen without touching anything
  • --archive NAME — target a specific archive instead of the latest (get names via --list-archives)

Root DB credentials come from MYSQL_ROOT_PASSWORD in the environment if set, otherwise from the ROOT_PASSWORD_FILE in your config (default /root/.mariadb-root.pw). Restore logs go to /var/log/borg/restore-*.log; RESTORE_LOGDIR overrides the directory (useful for testing).

restore.sh shares borg-backup.sh's lockfile (LOCKFILE in the config), so a restore refuses to start while the nightly backup is mid-run and vice versa.

For all restore.sh commands, select the deployment with BACKUP_CONF:

export BACKUP_CONF=/opt/backup-agent/configs/nexusvoice.conf

Or run without it if backup.conf is already symlinked beside the script.

5.1 Full disaster recovery (new or wiped server)

Use when the whole server/container is gone and you're rebuilding from scratch.

# 1. Reinstall borg, docker, and the mariadb container image/compose file
#    (not covered by restore.sh — this is infra provisioning).
# 2. Restore the passphrase file (from your password manager / secondary
#    backup — it is NOT stored in the repo it protects) to the path set in
#    BORG_PASSPHRASE_FILE, and the root DB password to ROOT_PASSWORD_FILE.
# 3. Preview:
./restore.sh full --dry-run
# 4. Run for real. --force is only required if $TARGET already has data in it
#    (e.g. a stale mount); omit it on a genuinely empty/fresh server:
./restore.sh full --force

This extracts the full content tree, starts the DB container and waits for it to report healthy, then restores every database dump (users/grants first).

Verify afterward:

  • docker ps shows the DB container running and healthy.
  • The application responds normally.
  • Spot-check row counts on a couple of tables against what you'd expect.

5.2 Single database restore

Use when one database got corrupted or someone ran a bad migration/query — this drops and recreates that database.

./restore.sh db shopdb --dry-run     # preview
./restore.sh db shopdb               # prompts: type "shopdb" to confirm

Non-interactive (e.g. scripted from a monitoring alert): add --yes to skip the typed confirmation.

Verify afterward: connect to the database and check the tables/row counts you expect.

5.3 Single file/directory restore

Use for accidental deletion or to inspect an old version — never touches the running database or container.

./restore.sh file path/relative/to/target/some-file.txt --dest /tmp/recovered

The final location of the recovered item is printed at the end. Borg preserves the absolute path it was archived with, so the file lands under /tmp/recovered/<TARGET>/....


6. Restore Drill Cadence

Quarterly, run a real full extract into a scratch directory to confirm backups are actually usable.

borg extract always extracts into the current directory; the archive name must come from borg list --short (plain borg list prints a formatted line, not a bare name). Source your config to get the right values:

source /opt/backup-agent/configs/nexusvoice.conf   # adapt
export BORG_REPO="$REPO"
export BORG_PASSCOMMAND="cat $BORG_PASSPHRASE_FILE"

mkdir -p /tmp/restore-drill && cd /tmp/restore-drill
LATEST=$(borg list --short | tail -1)
borg extract --lock-wait 600 "::$LATEST"

Confirm the dump files under ${DUMP_SUBDIR}/ are present, non-empty, and importable (mysql -u root -p < mariadb/dump/somedb.sql against a throwaway MariaDB container). Log the drill date and outcome somewhere durable (e.g. a team wiki page).