Restic + Backblaze B2 in homelab — backup that actually works, costs $1.20/mo, lets me sleep
After two years with various solutions (Borg, Duplicati, hand rsync) I landed on Restic + B2. Full setup with systemd timer, encryption, retention policy and Telegram alerts.

The first time I made a "real" backup was after losing a Proxmox VM with the betting-hope database in 2024. The SSD blew up mid-week, the last snapshot was 3 days old, so I lost 3 days of data. Since then I have a firm rule: data without backup is data with an expiry date.
Today my setup is Restic + Backblaze B2 for backups across 4 machines, 6 databases, ~80 GB. Cost: $1.20/mo. Reliability: tested restore monthly, works.
This is the exact setup, copy-paste, not philosophy.
Why Restic and not Borg / Duplicati / Restic Browser
Short:
- Borg, great, but chunked storage keeps data in a binary format hard to recover if you lose the binary. Restic has an open spec.
- Duplicati, pretty UI, but crashes on big repos, weak support for incremental verification.
- Restic, Go single binary, AES-256 + Poly1305 encryption, dedup, snapshots, 15+ backends supported (B2, S3, Azure, GCS, SFTP, REST etc.). Stable since 2017.
- Out from the start: bare rsync (no dedup, no encryption), tar.gz (no incremental).
Architecture — what to back up, where, how often
┌─────────────────────────────────────────┐
│ Main machine (homelab core) │
│ - /home/user (configs, projects) │
│ - /var/lib/docker/volumes (DB volumes) │
│ - /etc (system config) │
│ - PostgreSQL dump (every 6h) │
└─────────────────────────────────────────┘
│
│ restic backup --tag $(hostname)
▼
┌─────────────────────────────────────────┐
│ Backblaze B2 bucket: kkaletka-backup │
│ region: eu-central │
│ encryption: server-side + client-side │
│ retention: 7d daily + 4w weekly + 12m │
└─────────────────────────────────────────┘Four machines push to the same bucket, each with its own tag. Selective restore per host.
Setup, step by step
1. Install Restic (Ubuntu/Debian)
# Snap breaks dependencies, use github releases
wget https://github.com/restic/restic/releases/download/v0.17.3/restic_0.17.3_linux_amd64.bz2
bunzip2 restic_*.bz2
sudo install -m 755 restic_* /usr/local/bin/restic
restic version # 0.17.3 compiled with go1.232. Backblaze account + bucket
- Create account at backblaze.com (15 GB free, then $0.005/GB/mo)
- New Bucket:
kkaletka-backup, private, encryption: server-side, region: eu-central - Application Key with scope only to that bucket (don't use master key)
3. Init repo
# File with env vars (don't commit to repo!)
cat > /etc/restic/env <<EOF
B2_ACCOUNT_ID=your-key-id
B2_ACCOUNT_KEY=your-app-key
RESTIC_REPOSITORY=b2:kkaletka-backup:/
RESTIC_PASSWORD=$(openssl rand -base64 32)
EOF
chmod 600 /etc/restic/env
# Init (run ONCE for the entire repo lifetime!)
source /etc/restic/env
restic initCritical: save RESTIC_PASSWORD in a password manager. Without it, repo is useless. Lose the password → lose everything.
4. Backup script
#!/usr/bin/env bash
# /usr/local/bin/backup-restic.sh
set -euo pipefail
source /etc/restic/env
HOST=$(hostname)
LOG=/var/log/restic-${HOST}.log
# 1. Postgres dump
pg_dump -U postgres -d betting_hope | \
restic backup --stdin --stdin-filename "${HOST}-postgres-$(date +%F).sql" \
--tag postgres --tag $HOST 2>&1 | tee -a $LOG
# 2. File system
restic backup \
/home/kkaletka \
/etc \
/var/lib/docker/volumes \
--exclude-file=/etc/restic/excludes \
--tag files --tag $HOST 2>&1 | tee -a $LOG
# 3. Apply retention policy
restic forget --prune \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 12 2>&1 | tee -a $LOG
# 4. Integrity check (subset, full check 1x/mo)
restic check --read-data-subset=5% 2>&1 | tee -a $LOG5. Exclude file
# /etc/restic/excludes
node_modules
.next
__pycache__
*.pyc
*.tmp
*.log
**/cache/**
.cache
.git/objects/pack
target/debug
target/releaseCuts ~40% of the volume. node_modules especially, you restore npm install, not 800 MB of JS libraries.
6. Systemd timer (instead of cron)
# /etc/systemd/system/restic-backup.service
[Unit]
Description=Restic backup
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-restic.sh
EnvironmentFile=/etc/restic/env
Nice=10
IOSchedulingClass=idle
[Install]
WantedBy=multi-user.target# /etc/systemd/system/restic-backup.timer
[Unit]
Description=Daily restic backup at 03:30
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=15min
[Install]
WantedBy=timers.targetsudo systemctl enable --now restic-backup.timer
systemctl list-timers restic-backup.timer # verifyNice=10 + IOSchedulingClass=idle, backup doesn't crush the system. RandomizedDelaySec, not all machines start at exactly 3:30.
Telegram monitoring
A backup that doesn't call when it fails = no backup.
# Add to /usr/local/bin/backup-restic.sh
TELEGRAM_TOKEN="your-bot-token"
CHAT_ID="your-chat-id"
notify_failure() {
local msg="$1"
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=⚠️ Backup ${HOST} failed: ${msg}"
}
trap 'notify_failure "Script failed at line $LINENO"' ERRset -e + trap, every non-zero exit from the script body goes to Telegram with line number. After a year of using this I get ~3 alerts/year (mostly B2 maintenance windows).
Restore — the most important test
Untested backup = no backup (part 2).
# List snapshots
restic snapshots --tag postgres
# Restore a specific file from a specific snapshot
restic restore latest --target /tmp/restore --include /home/kkaletka/.env
# Restore postgres
restic dump latest /openclaw-master-postgres-2026-05-11.sql | \
psql -U postgres -d betting_hope_restoredI run a test restore once a month, to a separate VM, verify the database works. 15 minutes, 100% certainty.
Real costs
B2 storage: 80 GB × $0.005/GB/mo = $0.40/mo
B2 download (test): 5 GB × $0.01 = $0.05/mo
B2 API calls: ~$0.02/mo
─────────────────────────
~$0.47/mo ≈ $1.20/mo with overheadPlus optional second backup to Hetzner Storage Box (1 TB for ~$3/mo), useful if B2 goes down globally. Mine is:
# Additional weekly snapshot to Hetzner
restic -r sftp:[email protected]:/restic snapshots3-2-1 rule: 3 copies, 2 different media, 1 off-site. Production data → main disk + B2 + Hetzner.
What I'd do differently
- Started test restore earlier. First restore I did 4 months after setup, turned out
RESTIC_PASSWORDwas getting lost on subsequent runs. A test from day one would have caught it. - Separate bucket per machine. I keep everything in one, but that means each machine can potentially read another's backups. Separate buckets and app keys per host, better isolation.
- Healthchecks.io instead of Telegram only. For cron-style jobs healthchecks.io gives you "ping that didn't come", catches when the script didn't even fire (not just failed).
TL;DR — checklist
- Restic + Backblaze B2 ($1.20/mo for 80 GB)
-
RESTIC_PASSWORDin password manager, not just on disk - Exclude
node_modules,__pycache__,target/, saves 40% - Retention
--keep-daily 7 --keep-weekly 4 --keep-monthly 12 - Systemd timer (
Nice=10,IOSchedulingClass=idle,RandomizedDelaySec=15min) - Telegram/Healthchecks.io notify on failure
- Test restore once a month, to a separate VM
- Bonus: second off-site backup (Hetzner Storage Box)
Setup takes 2-3h, costs pennies, and lets you sleep. That's the last thing that should be on a budget.