Skip to content

Backup and Restore

Blake Foley edited this page Sep 17, 2026 · 1 revision

Backup and Restore

Scheduled, encrypted backups of the database and uploads/, plus the disaster-recovery path to restore one onto a fresh box.

Setting up backups

Backups are encrypted with a passphrase that lives only in a root-only file on the server, /etc/itflow/backup-passphrase — never on argv, never in the systemd unit itself:

sudo mkdir -p /etc/itflow
sudo bash -c 'openssl rand -base64 48 > /etc/itflow/backup-passphrase'
sudo chmod 600 /etc/itflow/backup-passphrase
sudo chown root:root /etc/itflow/backup-passphrase

Immediately copy that passphrase somewhere other than this server — a password manager, a printed copy in a safe, a secrets vault, anything off-box. An encrypted backup whose only decryption passphrase lives on the same disk as the backup provides no real protection if that disk is what's lost, stolen, or ransomwared.

Enable the scheduled timer:

sudo systemctl enable --now itflow-backup.timer
sudo systemctl list-timers itflow-backup.timer     # confirm the next scheduled run

Run deploy/backup.sh once by hand first to confirm it succeeds and see where it writes output, before trusting the timer to run it unattended:

sudo deploy/backup.sh --app-dir=<path> --passphrase-file=/etc/itflow/backup-passphrase

Runs as root (the final encrypted file is chmod 600, owned root:root — deliberately not readable by www-data, so a webshell can't read this instance's whole backup history).

What's inside a backup

Each run produces one file: <dest>/backup-<dbname>-<timestamp>.tar.gz.enc — an openssl enc -aes-256-cbc -pbkdf2 encrypted tar archive containing:

  • the mysqldump output
  • a small backup-manifest.json (installation ID + config_settings_enc_key, if the source instance ever set one — see below for why this travels separately from the SQL dump)
  • uploads/

The application also has its own independent, manual, on-demand backup feature reachable from inside the app (Settings → Backup → "Download Backup" / "Save to Server") that produces a similar database-dump-plus-uploads-zip on demand — useful before a risky change, but not encrypted and not scheduled, so it doesn't replace deploy/backup.sh for actual disaster-recovery purposes. The two coexist in the same backups/ directory without either deleting the other's files.

Restoring

deploy/restore.sh decrypts a backup-*.tar.gz.enc archive and overwrites an already-installed instance's database and uploads/ with what's inside — the "new server, only have my offsite encrypted backup" path. Run Bare Metal Install's deploy/install.sh first (or use its --restore-from flag directly, which calls this script for you) to get a fresh, empty instance, then point this at the backup:

sudo deploy/restore.sh --app-dir=<path> --backup=<path-to-backup.tar.gz.enc> \
    --passphrase-file=/etc/itflow/backup-passphrase --confirm-restore

What it does, in order

  1. Pre-restore safety backup of the target's current state before touching anything (mandatory, or an explicit --no-pre-restore-backup-confirmed opt-out — skip only if the target has nothing worth keeping).
  2. Decrypt + extract the backup archive.
  3. Recover the manifest (installation_id/config_settings_enc_key) if the archive has one. Older backups predate this and print a warning instead.
  4. DROP DATABASE + CREATE DATABASE, then import the SQL dump — deliberately not a plain import into whatever's already there. mysqldump's own per-table DROP TABLE IF EXISTS only covers tables that exist in the dump; a table that exists in the target but not in an older backup (schema drift between when the backup was taken and the target's current db.sql) would otherwise survive untouched. The app's own DB user already holds the grants this needs.
  5. Replace uploads/ with the backup's (rsync -a --delete), then restore ownership/permissions.
  6. Apply the recovered config_settings_enc_key to the target's config.php, if the manifest had one and it differs from what's already there.

Why the manifest exists

config_settings_enc_key lives only in config.php — never in the database, and therefore never in the SQL dump. A naive restore (dump + uploads only) onto a fresh instance with its own freshly-blank config.php would silently corrupt every setting that key protected (SMTP, IMAP, RMM/webhook secrets — mostly not applicable today, since scripts/setup_cli.php doesn't currently generate one by default, but the manifest travels defensively in case an instance set one by hand). The manifest is encrypted inside the same archive as everything else, so it's no less protected than the data it travels with.

Testing a restore safely

Test a restore before you need it for real. A backup you've never restored is a backup you don't actually have. Point --app-dir at a disposable second instance (deploy/install.sh --domain=<scratch>.example.com) rather than a production one you care about, restore into that, verify, then delete it. restore.sh has no special "dry run" mode of its own — the isolation comes entirely from which --app-dir you point it at.

One app-specific wrinkle

Credential vault data in the database is encrypted with each user's own zero-knowledge key, derived from their password — not a separate recoverable secret bundled into the SQL dump. As long as the admin account's password is unchanged after a restore, vault data decrypts normally.

Clone this wiki locally