← All Guides
intermediate

Restic Backups for a Homelab: Encrypted, Deduplicated, and Actually Restorable

Set up restic for homelab file-level backups to Backblaze B2 or a local NAS. Snapshots, retention, automated systemd timers, and the restore test that proves it works.

Budget Homelab ·
dockerbackuphomelabhow-to

This guide contains no paid affiliate links. It is configuration, not shopping.

I ran Duplicati for a long time and it did the job. Web UI, scheduled jobs, encrypted destinations, nothing to complain about on paper. What eventually pushed me off it was not a failure, it was a feeling: I could not tell you, on demand and without opening a browser, what was in the backup, when it last ran, or whether the thing I cared about would come back. Restic answers all three from a terminal in about ten seconds, and it does it the same way on every machine I run it on.

This guide sets up restic properly: repository, exclusions, retention, automation, and the restore test that is the entire point. It is the file-level and off-site half of a backup plan. The VM and container half lives in Proxmox Backup Server and Proxmox snapshots and backups, and the question of what deserves backing up at all is the homelab backup strategy question. Everything running here is visible on the stack page.

None of the hostnames, bucket names, or paths below are real. Substitute your own.

Quick Answer: Restic in Six Commands

# 1. install
sudo apt install restic

# 2. point at a destination and set a password
export RESTIC_REPOSITORY="b2:my-homelab-backup"
export RESTIC_PASSWORD="a-long-random-string"

# 3. create the repository
restic init

# 4. back something up
restic backup /opt /etc /home/user/docker

# 5. see what you have
restic snapshots

# 6. get it back
restic restore latest --target /tmp/restore-test

That is a working backup. The rest of this guide is the difference between a working backup and one you can rely on when the disk is gone.

Why Restic Instead of a UI Tool

Restic is a single static Go binary. No daemon, no database, no web server, no Java runtime. It encrypts on the client before anything leaves the machine, deduplicates at the block level, and stores snapshots in a content-addressed repository.

DuplicatiRestic
InterfaceWeb UICLI only
SchedulingBuilt insystemd timer or cron
EncryptionClient-side, AESClient-side, AES-256
DeduplicationBlock-levelBlock-level, across all snapshots
StateLocal SQLite databaseRepository is the state
Restore checkManualrestic check, plus scriptable restores
Learning curveLowMedium

The row that matters most is “state.” Duplicati keeps a local database describing the remote repository, and when those two drift you spend an evening repairing a database instead of restoring files. Restic’s repository is self-describing: hand a copy of it and the password to any machine with the binary and you can list and restore everything. That property is worth the missing UI.

The honest counterpoint: if you are new to self-hosting and backups are currently not happening, Duplicati’s UI is a real advantage and a running Duplicati job beats a perfect restic setup you never finish. Start there, come back here when the CLI stops feeling like a downgrade.

Prerequisites

Step 1: Install Restic

Debian and Ubuntu:

sudo apt update && sudo apt install restic
restic version

Distribution packages lag upstream. If yours is more than a couple of minor versions behind, take the official static binary instead:

curl -LO https://github.com/restic/restic/releases/latest/download/restic_linux_amd64.bz2
bunzip2 restic_linux_amd64.bz2
sudo install -m 755 restic_linux_amd64 /usr/local/bin/restic
restic version

One binary, no dependencies. This is also the reason restic is pleasant to run inside an LXC container: nothing to install around it.

Step 2: Pick a Destination

Restic calls the destination a repository, and the repository string tells it which backend to use.

# Backblaze B2
export RESTIC_REPOSITORY="b2:bucket-name:path/on/bucket"

# S3-compatible (MinIO, Wasabi, AWS)
export RESTIC_REPOSITORY="s3:https://s3.example.com/bucket-name"

# SFTP to another box
export RESTIC_REPOSITORY="sftp:backupuser@192.168.1.50:/srv/restic"

# A locally mounted NAS share
export RESTIC_REPOSITORY="/mnt/nas/backups/restic"

For off-site, B2 is the usual budget answer: cheap per-GB storage, S3-compatible API, and a free egress allowance that covers a realistic restore. A homelab backing up configs, container volumes, and documents typically sits in the tens of gigabytes, which is a couple of dollars a month. Media libraries are what break that budget, and they are also the one category most people can re-acquire, so exclude them and back up the metadata instead.

Create an application key scoped to that one bucket, not a master key. If the key on this host is ever exposed, you want the blast radius to be one bucket.

Step 3: Initialize the Repository

export B2_ACCOUNT_ID="your-key-id"
export B2_ACCOUNT_KEY="your-application-key"
export RESTIC_REPOSITORY="b2:my-homelab-backup:server1"
export RESTIC_PASSWORD="a-long-random-passphrase"

restic init

Read this next part twice, because it is the one mistake that cannot be fixed later.

The repository password is the only key to the data. Restic encrypts client-side. There is no provider-side recovery, no reset link, and no support path. Lose the password and the backup is a folder of noise. Put it in a password manager that is reachable from a machine other than the one you are backing up, because the scenario where you need it is the scenario where this host is gone.

A related trap: restic key passwd only rewraps the master key with a new password. It does not re-encrypt the data. If a password genuinely leaked, anyone who already has a copy of the repository can still open it, and the fix is a brand new repository, not a rotation.

Step 4: Decide What to Back Up

Back up things you cannot regenerate. Skip things you can.

Worth backing up:

Not worth backing up:

Write the exclusions to a file so they are version-controlled rather than buried in shell history:

sudo tee /etc/restic/excludes.txt > /dev/null <<'EOF'
**/node_modules
**/.cache
**/cache
**/tmp
*.tmp
*.iso
/var/lib/docker
/opt/jellyfin/cache
EOF

Databases need care. Restic copies files as it finds them. A live Postgres or SQLite file can be captured mid-write and restore into something that will not open. For anything with a database behind it, dump first and back up the dump:

docker exec my-postgres pg_dumpall -U postgres > /opt/backups/postgres-$(date +%F).sql

Flat-file app data is generally safe to copy hot. Databases are not, and finding that out during a restore is the expensive way to learn it.

Step 5: Run the First Backup

restic backup /opt /etc /home/user/docker \
  --exclude-file=/etc/restic/excludes.txt \
  --tag server1

The first run uploads everything and will take a while. Every run after that only sends changed blocks, so nightly backups of a stable homelab finish in seconds and add very little to the bill.

Tags are worth using from day one. If several hosts share a repository, --tag is how you keep their retention policies from stepping on each other later.

Check the result:

restic snapshots
restic stats latest

Step 6: Set a Retention Policy

Without pruning, a repository grows forever. forget decides which snapshots to keep; prune reclaims the space from data nothing references anymore.

restic forget \
  --tag server1 \
  --keep-daily 7 \
  --keep-weekly 4 \
  --keep-monthly 6 \
  --prune

That keeps a week of daily history, a month of weekly, and six months of monthly. Pick the numbers against the question “how long before I would notice this file was wrong?” Corruption you notice in a day needs less history than a quiet deletion you might not spot for a month.

Two things to know:

Always scope forget with --tag or --host when a repository is shared. An unscoped forget applies its policy to every snapshot in the repository, including other machines’ backups, and it is not obvious afterward that it happened.

--prune is the slow part. It rewrites pack files. Running it on every single backup is wasteful; a common pattern is backing up nightly and pruning on a separate weekly schedule.

Step 7: Automate It

Restic has no scheduler, deliberately. Use systemd.

Put the credentials in a root-only environment file, never in the unit itself:

sudo mkdir -p /etc/restic
sudo tee /etc/restic/env > /dev/null <<'EOF'
RESTIC_REPOSITORY=b2:my-homelab-backup:server1
RESTIC_PASSWORD=a-long-random-passphrase
B2_ACCOUNT_ID=your-key-id
B2_ACCOUNT_KEY=your-application-key
EOF
sudo chmod 600 /etc/restic/env

The wrapper script, /usr/local/bin/restic-backup.sh:

#!/usr/bin/env bash
set -euo pipefail

restic backup /opt /etc /home/user/docker \
  --exclude-file=/etc/restic/excludes.txt \
  --tag server1

restic forget \
  --tag server1 \
  --keep-daily 7 --keep-weekly 4 --keep-monthly 6

set -euo pipefail matters here. Without it a failing backup step still lets the script exit 0, and systemd reports a green run over a backup that did not happen.

The service, /etc/systemd/system/restic-backup.service:

[Unit]
Description=Restic backup
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
ExecStart=/usr/local/bin/restic-backup.sh

The timer, /etc/systemd/system/restic-backup.timer:

[Unit]
Description=Nightly restic backup

[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer
systemctl list-timers restic-backup.timer

Persistent=true means a run missed while the host was off fires at next boot. RandomizedDelaySec staggers multiple hosts so they do not all hit the same bucket at 02:30.

Then make it tell you when it breaks. A backup timer that silently fails is worse than no timer, because it manufactures confidence. Wire failures into whatever you already watch: the homelab email notifications guide covers systemd OnFailure= handlers, and Uptime Kuma can hold a push monitor that goes red when a nightly run stops checking in.

Step 8: Prove a Restore Works

This is the step people skip, and skipping it means everything above was theater.

Structural check, cheap enough to run weekly:

restic check --no-lock

That verifies the repository’s own integrity. It does not verify that your files come back, which is a different claim. For that, restore and compare:

mkdir -p /tmp/restore-test
restic restore latest --target /tmp/restore-test

# compare a real file against the live one
sha256sum /tmp/restore-test/opt/some-app/config.yml /opt/some-app/config.yml

Two checksums that match is proof. A restore that completed without error is not, and neither is a green timer.

Restore a single path when you only need one thing back:

restic restore latest --target /tmp/restore-test --include /opt/some-app

Or mount the repository and browse it like a filesystem, which is the fastest way to find the version of a file you actually want:

mkdir /mnt/restic
restic mount /mnt/restic
# snapshots appear under /mnt/restic/snapshots/

Put the restore test on a schedule. Monthly is enough for most homelabs, and it costs one download of a handful of files. The point is not the files. It is confirming the password still works, the credentials have not expired, and the repository is still there.

Common Problems

“repository is already locked exclusively”: a previous run died without releasing its lock, usually because the host rebooted mid-backup. Confirm nothing is actually running, then restic unlock. If it happens repeatedly, a backup is overlapping the next one and the schedule is too tight for how long the run takes.

The backup runs by hand but fails from systemd: nearly always the environment. Your interactive shell has the exported variables; the unit only has what EnvironmentFile= gives it. Check with systemctl status restic-backup.service and journalctl -u restic-backup.service -n 50.

Backups keep getting slower: check whether --prune is running on every invocation. Pruning rewrites pack files and gets more expensive as the repository grows. Move it to a separate weekly unit and the nightly run goes back to seconds.

The repository is much larger than the data: something big and churny is not excluded. restic stats --mode raw-data shows the real footprint. Log directories, transcoding caches, and database files that rewrite themselves wholesale are the usual offenders; a Postgres data directory backed up hot will store a near-complete new copy every night because deduplication cannot help when the underlying blocks all change.

A restore is slow from B2: that is egress, not restic. Restoring a single directory with --include rather than the whole snapshot is usually what you actually want anyway.

Running Restic Alongside Proxmox Backup Server

These are not competitors and you want both.

Proxmox Backup ServerRestic
Unit of backupWhole VM or LXCFiles and directories
RestoresEntire guest, or single files from withinFiles and directories
Typical destinationOn-site, another box or diskOff-site bucket
Recovers fromA broken guestA dead site

PBS gets a VM back quickly after you break it. Restic gets your data back when the building is gone. A budget homelab that runs PBS nightly to local storage and restic nightly to a bucket has both halves covered for a couple of dollars a month, which is the whole argument in self-hosting versus cloud over two years.

What I Would Do Differently

Two things, learned the boring way.

Scope the credentials before the first run, not after. A bucket-scoped application key costs nothing extra and removes an entire category of bad afternoon.

Write the retention policy down where the restore instructions live. Six months from now, “why is there no snapshot from March” has a good answer and a bad answer, and the difference is whether anyone recorded the intent.

Start with restic init, back up one directory, and restore it to /tmp before you automate anything. A backup you have personally watched come back is worth more than a perfect config you have never tested.