Backups¶
Backups are done with two tools: restic for files, and Proxmox Backup Server for whole VMs and LXC containers. File sync is not part of this. Syncthing puts files on devices, which is a different job.
Three things that are not backups
- Sync (Syncthing, Dropbox). Delete or encrypt a file on one device and the deletion syncs everywhere within seconds.
- Parity or RAID (SnapRAID, ZFS, mdadm). These survive a dead disk. They don't help with accidental deletion, ransomware, or the whole box dying.
- Snapshots on the same machine. They're gone if the machine is.
A backup is a separate, versioned copy you can go back in time with, ideally somewhere else. The usual rule is 3-2-1: three copies, on two different kinds of storage, one of them offsite.
The goal: two backup NAS boxes, two sites¶
Every device is backed up to two separate NAS boxes at two different physical locations, joined by Tailscale. Either site can burn down, flood or be burgled, and a full, versioned copy of everything still exists at the other.
flowchart LR
subgraph sources["Everything that gets backed up"]
laptops["Laptops"]
servers["Servers<br/>(Docker host, workstation)"]
nasdata["NAS data<br/>(photos, files, library)"]
vms["Proxmox VMs & LXCs"]
end
subgraph siteA["Site A"]
nasA["Backup NAS A<br/>restic repos"]
pbsA["PBS A"]
end
subgraph siteB["Site B"]
nasB["Backup NAS B<br/>restic repos"]
pbsB["PBS B"]
end
laptops & servers & nasdata -- restic --> nasA
laptops & servers & nasdata -- restic --> nasB
vms -- vzdump --> pbsA
pbsA -. "PBS sync job" .-> pbsB
- Files go to both sites directly. Each machine has one restic repository per backup NAS, with the same paths and schedule but run at different times. You could instead back up to one repo and
restic copyit to the second, but two independent repos mean one corrupted repo can't spread to the other. - VMs and LXCs go to one PBS, which syncs to the other. PBS has built-in sync jobs that pull a datastore from a remote PBS, so the hypervisor only uploads each backup once.
- Each site's NAS stays independent. It isn't a mirror of the other: a mistake replicated instantly to both copies is the failure this setup exists to avoid.
| Layer | Tool | Protects against |
|---|---|---|
| Database dumps | systemd timer + pg_dumpall |
A corrupted or half-written database inside a file-level backup. |
| Files → both NAS boxes | restic + Backrest | Deletion, ransomware, losing a whole site. Versioned back two years. |
| VMs & LXCs → PBS | Proxmox Backup Server | A guest's OS disk dying, or a bad upgrade. Restore the whole VM or container in minutes. |
| Backup NAS disks | mergerfs + SnapRAID | One disk in a backup NAS dying. |
The night¶
Jobs are staggered so they don't compete for disks and bandwidth:
| Time (UTC) | Job |
|---|---|
| 00:00 | Database dumps on the Docker host |
| 01:30 | Proxmox backs up all VMs and LXCs to PBS |
| 02:30 | Backrest on the NAS → backup NAS |
| 03:30 | Backrest on the Docker host → backup NAS |
| 05:00 (local) | SnapRAID diff / sync / scrub on the backup NAS |
| weekly | restic prune and check on each repo |
Database dumps¶
Copying a database's data directory while it's running can give you a backup that won't open. So once a night, before the file backups run, a systemd timer dumps every database to compressed SQL files with a date in the name:
#!/bin/bash
# Nightly dump of every DB container, run by a systemd timer (as root).
# No passwords: Postgres trusts its local socket, MariaDB reads the root
# password from its own container environment.
set -uo pipefail
BACKUPDIR=${BACKUPDIR:-$HOME/docker/db_backups}
KEEP_DAYS=2
STAMP=$(date +%Y%m%d%H%M)
mkdir -p "$BACKUPDIR"
status=0
dump() { # container name, then the command to run inside it
local c=$1 out="$BACKUPDIR/$1-$STAMP.sql.gz"
shift
if docker exec "$c" sh -c "$*" | gzip > "$out" && [ "$(gzip -dc "$out" | head -c 1 | wc -c)" -gt 0 ]; then
echo "ok: $c ($(du -h "$out" | cut -f1))"
else
echo "FAILED: $c" >&2; rm -f "$out"; status=1
fi
}
for c in immich-postgres miniflux-db forgejo-db; do # every Postgres container
dump "$c" 'exec pg_dumpall -U "${POSTGRES_USER:-postgres}"'
done
dump mariadb 'exec mariadb-dump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases --single-transaction'
find "$BACKUPDIR" -name "*.sql.gz" -mtime +$KEEP_DAYS -delete
exit $status
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
Only two days are kept locally, because the db_backups/ folder is included in the restic backup, which holds the long history.
Some details:
- No secrets in the script. The official Postgres image trusts connections over the local socket inside the container, so
docker exec … pg_dumpneeds no password. For MariaDB, the single quotes mean$MYSQL_ROOT_PASSWORDis expanded inside the container, from its own environment. The script can therefore live in git. pg_dumpall/--all-databasesdump everything in each container, so the script doesn't need to know any database or user names. Restore apg_dumpallfile withpsql -U postgres -f dump.sql.--single-transactiongives a consistent MariaDB dump without locking tables.- Empty dumps count as failures. A container that's gone or renamed otherwise leaves a 20-byte
.gzevery night that looks like a backup. The non-zero exit status marks the systemd unit as failed, which shows up in monitoring.
restic and Backrest¶
restic makes encrypted, deduplicated snapshots. Each run only uploads what changed, but every snapshot is a complete point-in-time copy you can browse or restore from. Backrest is a web UI and scheduler on top of restic: plans, retention, a browsable snapshot list, and notifications when something fails.
Both the NAS and the Docker host run Backrest in Docker and back up to the backup NAS over SFTP via Tailscale. Nothing has to be exposed to the internet, and each backup NAS just needs an SSH account. Adding a second site in Backrest is one more repo and one more plan pointing at it.
| NAS | Docker host | |
|---|---|---|
| Backs up | Personal files, photos (including Immich's library), Lightroom, the shared library | The whole compose repo and service data, DB dumps |
| Skips | — | Game libraries and other things that can be downloaded again |
| Repository | sftp:<user>@<backup-nas>:/srv/<pool>/<host>-backup |
same, one repo per host |
| Schedule | 02:30 daily | 03:30 daily |
| Keep | 14 daily, 8 weekly, 12 monthly, 2 yearly | same |
| Maintenance | prune weekly (repack when >10 % unused), check weekly |
same |
The source directories are mounted into the Backrest container read-only:
backrest:
image: garethgeorge/backrest:latest
volumes:
- $DOCKERDIR/backrest/data:/data
- $DOCKERDIR/backrest/config:/config
- $TEMPDIR/backrest/cache:/cache # restic cache, makes runs much faster
- $DOCKERDIR/backrest/.ssh:/.ssh:ro # key for the SFTP repository
- $USERDIR/docker:/backup_data:ro
environment:
- BACKREST_DATA=/data
- BACKREST_CONFIG=/config/config.json
- XDG_CACHE_HOME=/cache
Save the repository password somewhere else
restic encrypts everything with the repository password. Without it the backup is unreadable, including by you. Keep it in your password manager, and not only on the machine being backed up.
What to leave out: anything that can be downloaded again (game libraries, Docker images, caches). Backing up tens of TB of replaceable data costs more than it's worth. The irreplaceable things, such as photos, documents, config and databases, are a few TB.
Restoring from the command line, without Backrest:
export RESTIC_REPOSITORY=sftp:<user>@<backup-nas>:/srv/<pool>/<host>-backup
restic snapshots # list
restic ls latest /backup_data/paperless # browse
restic restore latest --target /tmp/restore --include /backup_data/paperless
restic mount /mnt/restic # or browse every snapshot as a folder
Proxmox backups¶
The hypervisor backs up every guest, both VMs and LXC containers, nightly to a Proxmox Backup Server (PBS), which runs as an LXC on the separate backup Proxmox host. PBS deduplicates across guests and nights, so keeping many restore points costs little space.
schedule 01:30
selection all guests, VMs and LXCs (minus a few excluded ones)
mode snapshot # guest keeps running during the backup
fleecing on # buffer writes locally so a slow link doesn't stall the VM
compress zstd
storage pbs # the remote PBS datastore
The data disks passed through to the VMs (the NAS's storage, the Docker host's data disks) are each marked backup=0 in the VM's hardware settings, so the job only takes the OS disks. Mount points on LXC containers have the same backup flag. Those are what you need to get a VM booting again after a failure or a bad upgrade. The data on the other disks is covered by restic instead, file by file.
Tip
Add PBS as storage with an API token that only has backup permissions on one datastore, rather than the root password.
SnapRAID and mergerfs¶
The backup NAS runs OpenMediaVault with four large disks: three for data and one for parity.
- mergerfs pools the three data disks into one folder (
/srv/mergerfs/<pool>). Each file lives whole on one ordinary ext4 disk, so any disk can be pulled out and read on another machine. - SnapRAID computes parity across them on a schedule. If one disk dies, its contents can be rebuilt from the others plus parity.
parity /srv/dev-disk-by-uuid-<parity>/snapraid.parity
content /srv/dev-disk-by-uuid-<data1>/snapraid.content
content /srv/dev-disk-by-uuid-<data2>/snapraid.content
data d1 /srv/dev-disk-by-uuid-<data1>
data d2 /srv/dev-disk-by-uuid-<data2>
data d3 /srv/dev-disk-by-uuid-<data3>
exclude lost+found/
exclude /tmp/
OMV's SnapRAID plugin runs a nightly script that does diff, then sync, then a scrub once a week that re-reads everything and checks it against parity, and emails the result. It can also refuse to sync when a run would delete or change more files than a threshold (delete/update threshold in the plugin, where 0 turns the check off). Setting it means a mass deletion or ransomware run doesn't get written into the parity, so the files can still be recovered from it.
Why SnapRAID rather than RAID or ZFS here:
- Mixed disk sizes are fine. The parity disk just has to be as big as the largest data disk.
- Only the disk being read spins up. Good for a box that mostly sits idle.
- No pool to lose. Lose more disks than you have parity and you lose only those disks' files, not everything.
The catch is that parity is only as fresh as the last sync. A disk that dies mid-afternoon loses that day's new files. That suits a backup target or an archive (written once, read many times), not a busy database.
Testing restores¶
A backup you've never restored from is a hope, not a backup. Every so often:
- restore one service's folder from restic into
/tmpand check it opens; - load last night's database dump into a throwaway container;
- restore a VM or LXC from PBS under a new ID, boot it with networking off, and then delete it.