Restic Backups - Encrypted, Deduplicated Backups Done Right
Status: Active
Last Updated: 2026-08-26
Category: Cloud - Phase 4: Backups & Disaster Recovery
Prerequisites: cloud-storage-concepts, minio-setup, docker-volumes
Time: 2-3 hours
Tags: restic, backup, restore, snapshots, deduplication, encryption, systemd-timer, s3, minio, disaster-recovery
Summary
restic is a modern backup program that does the three things most backup tools get wrong out of the box: it encrypts everything client-side before data leaves the machine, deduplicates across every snapshot so daily backups of a 100 GB server cost megabytes not gigabytes, and stores versioned snapshots you can browse and mount like a filesystem. This guide recaps the 3-2-1 rule, explains restic's core concepts, walks through initializing repositories on a local directory and on S3/MinIO, covers the essential command set (backup, snapshots, restore, check, forget --prune), and assembles a production-grade automated backup job with systemd service + timer plus monitoring so silent failures can't happen.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Apply the 3-2-1 rule to your own infrastructure
- โ Explain snapshots, content-defined deduplication, and restic's encryption model
- โ Initialize and use repositories on a local dir and an S3/MinIO backend
- โ Run backups, list/verify snapshots, restore files and whole snapshots
- โ
Design a retention policy with
forget --keepand prune safely - โ Automate with a systemd timer and monitor backup success/failure
Table of Contents
- Backup Strategy: The 3-2-1 Rule
- How restic Thinks: Snapshots, Dedup, Encryption
- Preparing a Repository
- The Essential Command Set
- Retention: forget --keep and Pruning
- Automation with systemd Service + Timer
- Monitoring Backup Success
- Troubleshooting & Common Pitfalls
- Key Takeaways
- Sources & Related
Backup Strategy: The 3-2-1 Rule
Before any tooling: the strategy that survives every failure mode.
| Digit | Rule | Example in this stack |
|---|---|---|
| 3 | Three copies of your data | Live data + local restic repo + offsite MinIO/cloud repo |
| 2 | Two different media/systems | Local disk + object storage on another host |
| 1 | One copy offsite | restic โ MinIO on a remote server or B2/Wasabi |
Two additions worth adopting:
- 3-2-1-1-0: add one offline/immutable copy (object-lock bucket, or a repo you only append to) and zero errors verified by regular
restic checkruns. - A backup is only real if you've restored from it. Schedule a test restore quarterly; see disaster-recovery.
What to back up on a typical Docker host: bind-mount and named-volume data (databases need dump-first handling โ see pitfalls), configs, compose files, and secrets. Not container images (rebuildable) and not /proc-style ephemera.
How restic Thinks: Snapshots, Dedup, Encryption
Three concepts cover 90% of the mental model:
Snapshots
Each restic backup run creates a snapshot: a point-in-time tree of the directories you backed up, tagged with host, time, and labels. Snapshots are cheap because they share data.
Content-Defined Deduplication
Files are split into variable-size chunks based on content (not fixed offsets). Each chunk is stored once, addressed by its SHA-256 hash. Consequences:
- Rename or move a file โ zero new data stored.
- Daily VM image backups where 2% changed โ ~2% new data.
- Two servers backing up the same OS files into one repo โ stored once.
This is why "snapshot per day" is a sane default rather than a storage bomb.
Encryption
Everything โ data chunks, filenames, metadata โ is encrypted on the client with AES-256-CTR + Poly1305 MAC before it's written to the repository. The backend never sees plaintext. That means:
- Your MinIO server, cloud provider, or stolen backup disk reveals nothing without the password.
- The password becomes the single point of failure: lose it, lose the backups. Store it in a password manager AND printed somewhere physical. There is no recovery path.
Repository layout is a flat structure of data/, index/, keys/, snapshots/, locks/ โ identical whether the repo sits on disk, SFTP, S3, or any supported backend, which is why tools work interchangeably.
Preparing a Repository
Install first:
# Debian/Ubuntu
sudo apt install restic # often older; prefer binary for latest features
# Official binary (recommended)
sudo wget -O /usr/local/bin/restic https://github.com/restic/restic/releases/download/v0.17.3/restic_0.17.3_linux_amd64.bz2
(Grab the current release filename from https://github.com/restic/restic/releases; decompress with bunzip2 and chmod +x.)
Option A: Local Directory Repository
export RESTIC_REPOSITORY=/srv/backups/server1
export RESTIC_PASSWORD='your-strong-passphrase' # see env-file pattern below
restic init
# created restic repository b61f2b5c9a at /srv/backups/server1
Good for fast local copies (second copy in 3-2-1). Put it on a separate disk from the live data, not inside /home.
Option B: S3 / MinIO Backend
restic speaks native S3 against Amazon, MinIO, Wasabi, Backblaze B2 (via its S3 API), etc. Per the official docs, set credentials and init with an s3: URL:
export AWS_ACCESS_KEY_ID=restic-backup-key # MinIO access key from the console
export AWS_SECRET_ACCESS_KEY=restic-backup-secret
export RESTIC_REPOSITORY=s3:http://localhost:9000/restic-backups # path-style: host/bucket
export RESTIC_PASSWORD='your-strong-passphrase'
restic init
# created restic repository 6ad29560f5 at s3:http://localhost:9000/restic-backups
Notes grounded in the restic docs:
- For Amazon proper:
restic -r s3:s3.us-east-1.amazonaws.com/bucket_name initโ region viaAWS_DEFAULT_REGIONor-o s3.region=us-east-1. - restic expects path-style URLs; virtual-hosted style (
bucket.s3.region.amazonaws.com) is not supported. MinIO serves path-style natively, which makes it a friction-free target. - If the bucket doesn't exist on Amazon it's auto-created; with MinIO create it first (
mc mb local/restic-backups) โ see minio-setup. - TLS endpoints:
s3:https://minio.example.com/bucket. Self-signed cert? Add--cacert /path/to/ca.pem.
Credential Hygiene: Env File Pattern
Never put the passphrase in scripts that might leak. Use a root-only env file:
sudo install -m 600 /dev/null /etc/restic-env
sudo tee /etc/restic-env >/dev/null <<'EOF'
RESTIC_REPOSITORY=s3:http://localhost:9000/restic-backups
AWS_ACCESS_KEY_ID=restic-backup-key
AWS_SECRET_ACCESS_KEY=restic-backup-secret
RESTIC_PASSWORD=your-strong-passphrase
EOF
Every command then becomes:
sudo bash -c 'source /etc/restic-env && restic snapshots'
The Essential Command Set
All examples assume the env file sourced (shown as $RESTIC ... shorthand).
backup โ create a snapshot
$RESTIC backup /etc /home /srv/docker/volumes --tag daily
# summary prints: scanned files, new/changed bytes ("added to the repo"),
# processed bytes โ tiny numbers prove dedup is working
Useful flags:
--exclude '*.tmp' --exclude-cachesโ skip noise--tag weekly/--host web1โ organize for later filtering--one-file-systemโ don't wander into mounts
Databases need consistent dumps, not raw file copies: run pg_dump/mysqldump into a staging dir first and back that up (script shown later).
snapshots โ list what exists
$RESTIC snapshots # all, grouped table
$RESTIC snapshots --tag daily --last # filtered
$RESTIC snapshots --json # machine-readable (for monitoring scripts)
restore โ get data back
# Whole snapshot to a directory (use a scratch dir, not over live data!)
$RESTIC restore latest --target /tmp/restore-test
# Single path out of a snapshot
$RESTIC restore latest:/home/alice/thesis.pdf --target /tmp/out
# Restore a specific snapshot by short ID from `snapshots`
$RESTIC restore 4a5b6c7d --target /tmp/out --include /etc/nginx
You can also browse without restoring:
$RESTIC ls latest # list files in snapshot
$RESTIC mount /mnt/restic # FUSE-mount all snapshots read-only
ls /mnt/restic/snapshots/latest/home/alice/
check โ verify integrity
$RESTIC check # metadata/index consistency (fast-ish)
$RESTIC check --read-data # reads ALL data packs; slow, schedule monthly/quarterly
$RESTIC check --read-data-subset=10% # spread full verification across runs
This is your "3-2-1-0" verification digit โ an unverified backup is a hope, not a backup.
Diffing and stats (nice-to-haves)
$RESTIC diff <snap-id-1> <snap-id-2>
$RESTIC stats latest --mode restore-size
$RESTIC stats --mode raw-data # actual repo size after dedup
Retention: forget --keep and Pruning
Snapshots accumulate forever unless told otherwise. forget removes snapshots from the index; --prune additionally deletes now-unreferenced data from the repo (recovering space).
# Keep: 7 daily, 5 weekly, 12 monthly, 3 yearly โ plus ALL snapshots from the last 24h.
# Tags/hosts filter applies per policy group.
$RESTIC forget \
--host "$HOSTNAME" \
--keep-daily 7 \
--keep-weekly 5 \
--keep-monthly 12 \
--keep-yearly 3 \
--keep-within 24h \
--prune
Key semantics:
- Policies keep the last snapshot in each bucket (day/week/month boundaries), not the first โ old-but-unique snapshots survive.
--keep-within 24hguarantees nothing made today gets pruned even mid-policy-boundary weirdness.- Add
--dry-runfirst! It prints keep/remove decisions without touching anything. Always rehearse retention changes. --pruneis safe but I/O-heavy; running it nightly is fine at homelab scale, weekly for big repos. Modern restic defaults to space-efficient repacking.unlockremoves stale locks after a crashed backup (only when sure no other process runs):$RESTIC unlock.
Grandfather-father-son (GFS) as above gives roughly: a week of dailies, a month+ of weeklies, a year+ of monthlies โ a few hundred snapshots total for pennies of deduplicated storage.
Automation with systemd Service + Timer
Cron works, but systemd timers give you logging to the journal, dependency ordering, and missed-run catch-up. Three files:
/etc/systemd/system/restic-backup.service
[Unit]
Description=Restic backup to MinIO S3
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=oneshot
EnvironmentFile=/etc/restic-env
ExecStartPre=/usr/local/bin/dump-databases.sh /var/backups/dbdump
Nice=10
IOSchedulingClass=idle
ExecStart=/usr/bin/restic backup /etc /home /srv/docker/volumes /var/backups/dbdump --tag auto
ExecStartPost=/usr/local/bin/restic-forget.sh
ExecStartPost=/usr/local/bin/notify-backup-result success
OnFailure=/usr/local/bin/notify-backup-result failure
/etc/systemd/system/restic-backup.timer
[Unit]
Description=Nightly restic backup
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=15m
Persistent=true # run missed jobs after downtime/reboot
[Install]
WantedBy=timers.target
Supporting scripts
/usr/local/bin/restic-forget.sh (retention + verify in one place):
#!/usr/bin/env bash
set -euo pipefail
source /etc/restic-env
restic forget --host "$(hostname)" \
--keep-daily 7 --keep-weekly 5 --keep-monthly 12 \
--keep-within 24h --prune
restic check
/usr/local/bin/notify-backup-result (hook for Uptime Kuma/Discord/email):
#!/usr/bin/env bash
STATE="$1"
KUMA_URL="${KUMA_PUSH_URL:-}" # e.g. http://kuma.local:3001/api/push/AbCdEf?status=up&msg=OK&ping=
if [[ -n "$KUMA_URL" ]]; then
status=$([[ "$STATE" == success ]] && echo up || echo down)
msg=$([[ "$STATE" == success ]] && echo ok || echo FAILED)
curl -fsS --max-time 10 "${KUMA_URL%%\?*}?status=${status}&msg=${msg}&ping=" >/dev/null || true
fi
logger -t restic "backup result: $STATE"
Enable:
chmod +x /usr/local/bin/{dump-databases.sh,restic-forget.sh,notify-backup-result}
sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer
systemctl list-timers restic-backup.timer # confirm next run time
sudo systemctl start restic-backup.service # fire once now, watch journalctl -u restic-backup -f
Monitoring Backup Success
Backups fail quietly (full disk, expired key, network blip) and nobody notices until restore day. Layer three checks:
Push heartbeat โ the notify script pings Uptime Kuma's push monitor; Kuma alerts if no ping arrives within N hours (catches "job never ran"). See uptime-kuma-setup.
Journal-based alerting โ Netdata or a log watcher flags
logger -t resticfailure lines. Quick manual audit:journalctl -u restic-backup.service --since "7 days ago" | grep -c "backup result: success"Restore drill โ quarterly, actually restore something and diff it:
$RESTIC restore latest --target /tmp/drill && diff -r /tmp/drill/etc /etc | head
Bonus freshness check for scripts/cron-emails:
LAST=$(restic snapshots --latest 1 --json | jq -r '.[0].time')
AGE_HOURS=$(( ($(date +%s) - $(date -d "$LAST" +%s)) / 3600 ))
[[ $AGE_HOURS -gt 26 ]] && echo "ALERT: last backup ${AGE_HOURS}h ago" >&2
Troubleshooting & Common Pitfalls
Fatal: wrong password or no key found
Passphrase mismatch โ usually a stale env var in your shell overriding the env file. Check with env | grep RESTIC. There is no reset; this is why the passphrase lives in two places.
Backing up databases directly from data dirs
Raw volume copies of a running Postgres/MySQL may be inconsistent. Always pg_dump/mysqldump (or stop the container) first โ the ExecStartPre hook above exists exactly for this.
Repo grows unboundedly
Forgetting to run forget --prune (snapshots pile up), or huge churn files (VM images, logs being rewritten) defeating dedup. Check what changed: restic diff two snapshots. Consider excluding volatile paths.
check --read-data takes forever
It re-reads every byte. Use --read-data-subset=10% monthly instead, or schedule a full read annually.
Stale lock after crash
Fatal: unable to create lock in backend when nothing else is running โ restic unlock. Verify no concurrent restic processes first (pgrep restic).
S3 backend errors: SignatureDoesNotMatch / access denied
MinIO access-key rotation broke the env file, or clock skew between hosts. Also confirm the key's policy allows the repo bucket (minio-setup).
Timer shows "Trigger: n/a"
Timer unit not enabled or Persistent=true missing while the machine was off. systemctl enable --now restic-backup.timer and check list-timers.
Restored files have odd ownership
Restic preserves uid/gid; restoring as non-root maps them oddly. Restore as root, or use --user/--group options on restore.
Key Takeaways
- 3-2-1 (or 3-2-1-1-0) is the plan; restic is just the tool. Offsite + verified beats fancy.
- Client-side encryption means the password is everything โ protect it like the data itself.
- Content-defined chunking makes daily snapshots nearly free; watch "added to repo" shrink after run #1.
- Same commands everywhere: swap
RESTIC_REPOSITORYbetween local dir ands3:http://minio:9000/bucket. - Rehearse retention with
--dry-run; verify withcheck; prove value with test restores. - systemd service + timer + push heartbeat = backups that tattle on themselves instead of failing silently.
Sources & Related
Web Sources
- restic documentation โ Preparing a New Repository (S3/Minio): https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html
- restic docs โ Backing Up / Restore: https://restic.readthedocs.io/en/stable/040_backup.html and https://restic.readthedocs.io/en/stable/050_restore.html
- restic docs โ Removing snapshots (forget/prune policies): https://restic.readthedocs.io/en/stable/060_forget.html
- restic docs โ Integrity checking: https://restic.readthedocs.io/en/stable/075_scripting.html#check
- MinIO docs (S3 target setup): https://min.io/docs/minio/container/index.html
- restic releases: https://github.com/restic/restic/releases
Related KB Articles
- minio-setup โ standing up the S3 backend used in this guide
- cloud-storage-concepts โ object storage fundamentals
- kb/infrastructure/disaster-recovery โ full DR planning around these backups
- kb/containers/docker-volumes โ identifying what volume data to back up
- kb/observability/uptime-kuma-setup โ push monitors for backup heartbeats
- kb/sysadmin/system-admin-basics โ systemd units/timers refresher
Change Log
- 2026-08-26: Initial draft created via headless-browser web research session.