Blog
ENPL

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.

·6 min read
Restic + Backblaze B2 in homelab — backup that actually works, costs $1.20/mo, lets me sleep

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.23

2. 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 init

Critical: 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 $LOG

5. Exclude file

# /etc/restic/excludes
node_modules
.next
__pycache__
*.pyc
*.tmp
*.log
**/cache/**
.cache
.git/objects/pack
target/debug
target/release

Cuts ~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.target
sudo systemctl enable --now restic-backup.timer
systemctl list-timers restic-backup.timer  # verify

Nice=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"' ERR

set -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_restored

I 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 overhead

Plus 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 snapshots

3-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_PASSWORD was 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_PASSWORD in 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.