Overview
A RAID array, a ZFS mirror, or a nightly copy to a second disk in the same rack is not a backup strategy. Ransomware, a bad rm -rf, a fire, or a flooded basement takes out the data and the "backup" together. The long-standing rule of thumb is 3-2-1: three copies, on two kinds of media, with one copy offsite. Most homelabs satisfy the first two and quietly skip the third because offsite backup sounds like a project.
It does not have to be. restic is a single-binary backup program that encrypts everything client-side (AES-256 with Poly1305 authentication), splits files into content-defined chunks so repeated runs only upload what changed, and speaks to local disks, SFTP, S3-compatible stores, Backblaze B2, and anything rclone supports. Backrest is a web UI and orchestrator that wraps restic: it manages repositories, runs backup plans on a cron schedule, applies retention policies, schedules prune and integrity checks, and lets you browse and restore snapshots from a browser.
This build produces:
- A Backrest container that backs up selected Docker volumes and config directories nightly.
- An encrypted restic repository in a Backblaze B2 bucket, so the provider only ever sees ciphertext.
- A retention policy (7 daily, 4 weekly, 6 monthly) with scheduled prune and
restic check. - Pre-backup hooks that dump databases to flat files, so you never snapshot a live database directory mid-write.
- A documented restore drill, because a backup you have never restored is a hope, not a control.
It complements the monitoring side of the lab: the Prometheus and Grafana stack can alert when a backup goes stale, and the Vaultwarden build is a prime candidate for the data set you protect first.
Architecture
Docker host Offsite
+-----------------------------------+ +---------------------------+
| /srv/docker-apps (read-only) | | Backblaze B2 bucket |
| /srv/backups/dumps (db dumps) | | homelab-restic |
| | | | (ciphertext only) |
| v | HTTPS | |
| Backrest container :9898 |------->| restic repo: |
| - plans (cron) | | data/ index/ snapshots/ |
| - restic engine | | keys/ config |
| - hooks (dump, notify) | +---------------------------+
| - retention + prune + check |
+-----------------------------------+
^
| HTTPS via reverse proxy (LAN or tunnel), basic auth enabled
Admin browserDesign decisions worth making deliberately:
- Encryption happens on the host. The restic repository password never leaves your side. Lose it and the data is unrecoverable; store it in your password manager and offline.
- Read-only source mounts. Backrest only needs to read what it backs up. Mounting sources
:romeans a compromised backup UI cannot modify production data. - Dumps, not live database files. Copying a PostgreSQL or SQLite data directory while the engine is running can capture an inconsistent state. A pre-backup hook produces a clean dump file into a directory that is itself part of the backup.
- Separate B2 application key. Create a key scoped to the one bucket, not your master key.
- One repository per trust domain. Start with one repo for the whole host; split later if different data needs different passwords or retention.
Prerequisites
- A Docker host with Docker Compose v2 and about 1 GB RAM to spare.
- A Backblaze account (B2 has a small free tier; paid storage is billed per GB, so check current pricing for your volume).
- A list of what you actually need to restore: compose files,
.envfiles, app volumes, and database dumps. Cache directories and media you can re-download do not belong here.
Step 1: Create the B2 bucket and key
In the Backblaze console:
- Create a private bucket, for example
homelab-restic-<random-suffix>(bucket names are globally unique). - Leave server-side encryption off; restic already encrypts, and double encryption adds nothing here.
- Under Application Keys, add a key restricted to that single bucket with read and write access. Copy the
keyIDandapplicationKeyimmediately; the secret is shown once.
Note on lifecycle rules: restic manages its own pruning, so choose the bucket lifecycle setting keep only the last version. Otherwise pack files that restic deletes linger as hidden versions and quietly inflate your bill.
Step 2: Prepare the host directories
sudo mkdir -p /srv/backrest/{config,data,cache}
sudo mkdir -p /srv/backups/dumps
sudo chown -R root:root /srv/backrest
sudo chmod 700 /srv/backrest/configStore the secrets in an env file with tight permissions, kept out of version control:
sudo install -m 600 /dev/null /srv/backrest/backrest.env
sudo tee /srv/backrest/backrest.env >/dev/null <<'EOF'
B2_ACCOUNT_ID=<your-b2-keyID>
B2_ACCOUNT_KEY=<your-b2-applicationKey>
RESTIC_PASSWORD=<long-random-passphrase>
EOFGenerate the passphrase with something like openssl rand -base64 36, and save it in two places right now.
Step 3: Deploy Backrest
# /srv/backrest/compose.yml
services:
backrest:
image: ghcr.io/garethgeorge/backrest:latest # pin a version tag in production
container_name: backrest
hostname: backrest-homelab
restart: unless-stopped
ports:
- "127.0.0.1:9898:9898"
environment:
- BACKREST_DATA=/data
- BACKREST_CONFIG=/config/config.json
- XDG_CACHE_HOME=/cache
- TMPDIR=/tmp
- TZ=America/Edmonton
env_file:
- /srv/backrest/backrest.env
volumes:
- /srv/backrest/data:/data
- /srv/backrest/config:/config
- /srv/backrest/cache:/cache
- /srv/docker-apps:/userdata/docker-apps:ro
- /srv/backups/dumps:/userdata/dumps:ro
security_opt:
- no-new-privileges:truecd /srv/backrest
docker compose up -d
docker compose logs --tail 20 backrestThe port is bound to loopback on purpose. Put a reverse proxy in front for TLS (see the Traefik build) rather than exposing 9898 on the LAN. On first visit to the UI, Backrest asks you to set a username and password; do this before anything else, since the UI can run commands on the host's behalf through hooks.
Pin the image to a specific version tag once you have confirmed it works. latest on a backup tool is how a surprise upgrade lands during the one week you needed a restore.
Step 4: Add the repository
In the UI, choose Add Repo and fill in:
| Field | Value |
|---|---|
| Repo name | b2-homelab |
| Repository URI | b2:homelab-restic-<suffix>:host1 |
| Password | the same value as RESTIC_PASSWORD (or let Backrest generate one and store it) |
| Env vars | B2_ACCOUNT_ID, B2_ACCOUNT_KEY |
| Prune policy | schedule weekly, max unused 10% |
| Check policy | schedule weekly, read-data subset 5% |
The host1 path component lets several machines share one bucket with separate repositories. Backrest runs restic init for you on save and reports any credential error immediately.
Equivalent plain-restic commands, useful for understanding what Backrest is doing or for recovery from any machine:
export B2_ACCOUNT_ID=... B2_ACCOUNT_KEY=... RESTIC_PASSWORD=...
export RESTIC_REPOSITORY="b2:homelab-restic-<suffix>:host1"
restic init
restic snapshots
restic stats --mode raw-dataStep 5: Dump databases before the backup
Create a script that writes consistent dumps into the dumps directory. Adjust container names to your stack:
#!/usr/bin/env bash
# /srv/backups/dump-databases.sh
set -euo pipefail
OUT=/srv/backups/dumps
mkdir -p "$OUT"
# PostgreSQL
docker exec app-db pg_dumpall -U postgres | gzip > "$OUT/postgres-all.sql.gz.tmp"
mv "$OUT/postgres-all.sql.gz.tmp" "$OUT/postgres-all.sql.gz"
# SQLite (online-safe copy via the backup API)
docker exec app sqlite3 /data/app.db ".backup '/data/app.db.bak'"
docker cp app:/data/app.db.bak "$OUT/app.db"
find "$OUT" -name '*.tmp' -deleteWriting to a .tmp name and renaming means a failed dump never overwrites yesterday's good file with a truncated one.
Because the Backrest container cannot call docker exec on the host, run this script from the host's cron or a systemd timer a few minutes before the backup window:
# /etc/cron.d/backup-dumps
45 1 * * * root /srv/backups/dump-databases.sh >> /var/log/backup-dumps.log 2>&1Backrest also supports command hooks on events such as snapshot start and snapshot error, which is a good place for notifications. If you prefer hooks for the dump step, mount a script into the container and make sure it only uses tools the image contains.
Step 6: Create the backup plan
In the UI, choose Add Plan:
| Field | Value |
|---|---|
| Plan name | docker-apps-nightly |
| Repository | b2-homelab |
| Paths | /userdata/docker-apps, /userdata/dumps |
| Excludes | **/cache, **/node_modules, **/*.log, **/tmp |
| Schedule | cron 0 2 * * * |
| Retention | keep last 7 daily, 4 weekly, 6 monthly |
Excludes matter: they cut upload time and cost. Review them against what each app actually stores. For anything where you are unsure, back it up first and prune later; the reverse mistake is only discovered during a disaster.
The retention values map to the restic forget policy:
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --pruneBackrest applies this per plan and runs the heavier repository-level prune and check on their own schedules. Stagger them so prune, check, and backup do not contend for the repository lock at the same time. Pruning needs exclusive access, and a backup that starts mid-prune will wait or fail.
Click Backup Now for the first run. Expect it to take a while; the initial upload is the whole data set, and subsequent runs only send changed chunks.
Testing
A backup system is only proven by restores. Do all of these before trusting it.
1. Confirm the snapshot exists and has the right content
docker exec backrest restic -r b2:homelab-restic-<suffix>:host1 snapshots
docker exec backrest restic -r b2:homelab-restic-<suffix>:host1 ls latest --long | head -40(If you exec into the container, the credentials come from its environment. From another machine, export the three variables first.)
2. Verify repository integrity
restic check # structure and index
restic check --read-data-subset=5% # also re-downloads and verifies 5% of pack data3. Restore to a scratch directory and compare
mkdir -p /tmp/restore-test
restic restore latest --target /tmp/restore-test --include /userdata/dumps
gunzip -c /tmp/restore-test/userdata/dumps/postgres-all.sql.gz | headBetter still, load the dump into a throwaway PostgreSQL container and run a query against it. A restore that produces a file is a start; a restore that produces a working service is the actual test.
4. Test from a clean machine
Spin up a fresh VM or container with only restic installed and the three secrets. If you can list and restore from there, you can recover from losing the entire host. This is the test that proves your secrets are stored somewhere other than the dead server.
5. Test failure alerting
Temporarily put a wrong key in the repository settings and trigger a backup. Confirm you get a notification. Silent failure is the most common way homelab backups die.
Deployment
- Reverse proxy and TLS. Publish the UI only through your TLS proxy, ideally restricted to the LAN or behind an identity-aware layer such as the Cloudflare Zero Trust tunnel. Keep Backrest's own authentication enabled regardless.
- Notifications. Add a Backrest hook on backup-error and check-error events that posts to a Discord, Slack, or ntfy webhook. A healthchecks-style "dead man's switch" ping on success is even better, because it also catches the case where the whole host is down.
- Monitoring staleness. Alert if the newest snapshot is older than 36 hours. A simple option is a cron job that parses
restic snapshots --json --latest 1and exposes the timestamp to Prometheus via the node exporter textfile collector. - Secret handling. Keep
backrest.envout of git. Store the restic passphrase and B2 credentials in a password manager and in one offline location (printed, or on an encrypted USB drive). - Upgrades. Pin image tags, read release notes, and upgrade deliberately. After each upgrade, run a manual backup and a
restic check. - Bandwidth. If your uplink is limited, set an upload cap in the repository's flags (restic supports
--limit-upload) or schedule the job overnight. - Cost control. Run
restic statsmonthly, check the B2 bill against it, and tighten excludes or retention if growth surprises you.
Extensions and Next Steps
- A second repository. Add a local repo on a separate disk or NAS for fast restores, and keep B2 as the disaster tier. Same plan, two repos, gives you a fuller 3-2-1.
- Ransomware resistance. B2 supports Object Lock. Combined with a retention window, it prevents even a compromised key from deleting recent data. Note that Object Lock interacts with restic's prune; test it on a throwaway bucket first, and consider a separate, more privileged maintenance key used only for pruning.
- Hot-restore automation. Script a monthly restore drill that restores a sample, loads the database dump, and posts pass or fail to chat.
- Per-application plans. Give critical, small data (password manager, DNS config, secrets) its own plan with a more frequent schedule.
- Infrastructure as code. Template the Backrest config and compose file in git, with secrets injected from your vault, so the backup server itself is rebuildable.
- Image and VM backups. Pair file-level restic with periodic exports of VM or hypervisor images for bare-metal recovery.
- Compliance evidence. Log each successful restore test with a date; if you run a small business lab, that record is useful evidence for backup and recovery controls.
Once the first scheduled run succeeds, the restore drill passes, and the staleness alert is wired up, you have the offsite copy that most homelabs are missing. Next, tackle what to protect first: the data you could not rebuild in a weekend.