Introduction: Backup Systems Don’t Die Because Restic Crashes
Restic itself is not the weak link — its deduplication, snapshots, and encryption are benchmark-quality in the open-source backup world. But in audits I’ve done, 90% of VPS backup systems fail in strikingly similar ways:
- The S3 bucket is deleted in error, or the billing account is compromised and the bucket goes with it
- The backup key and the backup data live on the same machine, so one ransomware blast takes both
- There’s a “copy” but no offsite replica — when the datacenter goes down, source and backup are both unreachable
- No one has ever actually done a real restore; the backup turns out to be unrestorable on the day it matters
- The bucket has no object lock, so an attacker with write permission can delete the most recent snapshots
Restic is a tool, not a strategy. This article designs the backup system as a security asset: 6 layers of defense in depth + a 2-1-1 retention strategy + verifiable restores, driving data-loss probability to an engineering near-zero.
1. Threat Model: Know What You’re Actually Defending Against
| Threat | Likelihood | Blast Radius | Layers Needed |
|---|---|---|---|
| Accidental delete of DB / bucket | High | Fatal | Client-side encryption + object lock |
| Billing account stolen, bucket deleted | Medium | Fatal | Cross-region replica + separate account |
| Ransomware on the VPS | Medium | Fatal | Key separation + object lock |
| Datacenter outage / network loss | Low | High | Offsite replication |
| Key lost at restore time | Medium | Fatal | Multi-copy key storage |
| Backup “exists” but is corrupt | Medium | High | Periodic recoverability drills |
Key insight: no single layer’s failure is fatal; the probability of all six failing simultaneously is negligibly small. That’s the value of defense in depth.
2. The 6 Layers of Encryption and Protection
Layer 1: Client-Side AES-256-CTR (Restic Native)
export RESTIC_REPOSITORY=s3:minio-endpoint:backup-bucket
export RESTIC_PASSWORD_FILE=~/.con...stic -r $RESTIC_REPOSITORY init
restic -r $RESTIC_REPOSITORY backup /etc /home /var/lib/docker
Restic encrypts with AES-256-CTR and hashes directory/file metadata. Even if the entire S3 bucket is downloaded, the attacker only has ciphertext.
Verify: restic check --read-data confirms the repository is intact.
Layer 2: S3 Server-Side Default Encryption (SSE-S3)
aws s3api put-bucket-encryption \
--bucket backup-bucket \
--server-side-encryption-configuration '{
"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]
}'
Even if client-side encryption is somehow bypassed (say, plaintext captured from server memory), static data is still encrypted at rest. On Cloudflare R2, SSE is free; MinIO supports it natively too.
Layer 3: KMS Envelope Encryption (Optional, for Compliance)
For SOC2 / graded-protection requirements, use SSE-KMS with a dedicated KMS key:
aws s3api put-bucket-encryption \
--bucket backup-bucket \
--server-side-encryption-configuration '{
"Rules":[{"ApplyServerSideEncryptionByDefault":{
"SSEAlgorithm":"aws:kms",
"KMSMasterKeyID":"arn:aws:kms:.../key/backup-master"
}}]
}'
R2 has no KMS — substitute a self-hosted Vault managing restic keys, or accept SSE-S3 as the equivalent for that layer.
Layer 4: Bucket-Level Object Lock
aws s3api put-bucket-object-lock-configuration \
--bucket backup-bucket \
--object-lock-configuration '{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"GOVCOMPLIANCE","Days":90}}}'
In GOVCOMPLIANCE mode, even the root account cannot delete objects within 90 days. Ransomware’s “delete the latest backups” move is neutralized.
Layer 5: Cross-Region Replication
Primary bucket (eu-central-1 / region A)
│ CRR async replication
▼
DR bucket (us-east-1 / region B, or Cloudflare R2)
R2’s CRR is free with zero egress fees — the cheapest offsite DR option for a 1C2G VPS. For MinIO clusters, use mc replicate.
Cost: R2 storage $0.015/GB/month; 100GB of backups ≈ $1.5/month; CRR costs nothing extra.
Layer 6: Key Separation (The Most Overlooked Layer)
~/.config/restic/password ──► encrypted copy in 1Password
printed copy in a home safe (different location)
read-only copy on a separate VPS
Three iron rules:
- The key never exists in plaintext on the VPS that generates the backups (use
RESTIC_PASSWORD_FILEwith periodic cleanup, or pull it from the 1Password CLI each run) - The printed copy lives in a physically different safe
- The offsite VPS copy is read-only with minimal permissions
3. The 2-1-1 Retention Strategy: Governing Quantity Too
2-1-1 principle: at least 2 copies of data, on at least 1 different medium, with 1 copy kept offsite.
| Copy | Location | Medium | Retention |
|---|---|---|---|
| #1 | Source VPS, local | Block-level restic repo (NFS/local disk) | Daily, 7 days |
| #2 | Primary bucket (S3/R2, same region) | S3 objects | Daily 30 days + monthly 12 months |
| #3 | DR bucket (offsite) | S3 objects (CRR replica) | Monthly 12 months + yearly 3 years |
One restic command handles retention:
restic -r $RESTIC_REPOSITORY forget --keep-daily 7 \
--keep-weekly 4 --keep-monthly 12 --keep-yearly 3 --prune
Cost math (1C2G VPS, 100GB of data):
- Local block repo: spare SSD space, $0
- Primary bucket R2: $1.5/month
- DR bucket R2: $1.5/month
- Total: $3/month — versus $15–30/month for managed backup SaaS like Pico. You save $12–27/month and keep full data sovereignty.
4. Recoverability Drills: Storing It Is Not Enough
Run an automated restore drill on the 1st of every month, scripted so “the backup actually comes back” is verified, not assumed:
#!/usr/bin/env bash
# /usr/local/bin/backup-drill.sh
set -euo pipefail
DRILL_DATE=$(date +%Y%m%d)
DRILL_DIR=/tmp/drill-$DRILL_DATE
restic -r $RESTIC_REPOSITORY restore latest --target $DRILL_DIR
# Verify 3 critical paths
test -d "$DRILL_DIR/etc/nginx" || { echo "FAIL: nginx configs missing"; exit 1; }
test -f "$DRILL_DIR/home/app/.env" || { echo "WARN: .env not backed up"; }
diff -rq $DRILL_DIR/etc /etc 2>/dev/null | head -5
# Hash-check a random restored file
SAMPLE=$(find $DRILL_DIR/home -type f 2>/dev/null | shuf -n1)
[ -n "$SAMPLE" ] && sha256sum "$SAMPLE" | tee /var/log/backup-drill.log
# Guard: a drill that restored nothing is a failure
if [ "$(find $DRILL_DIR -type f | wc -l)" -lt 100 ]; then
echo "FAIL: restored fewer than 100 files — drill invalid"; exit 1
fi
# Clean up drills older than 30 days
find /tmp -maxdepth 1 -name "drill-*" -mtime +30 -exec rm -rf {} +
echo "DRILL PASSED $DRILL_DATE" >> /var/log/backup-drill.log
Add to cron:
0 3 1 * * /usr/local/bin/backup-drill.sh >> /var/log/backup-drill.log 2>&1
Key: feed drill logs into Uptime Kuma or Prometheus; two consecutive failures page someone. Backup system availability must be monitored too.
5. One-Script Deployment: 6 Layers + 2-1-1 in One Pass
#!/usr/bin/env bash
# restic-six-layer-setup.sh — run on the source VPS
set -euo pipefail
# 0. Install
curl -fsSL https://restic.net/latest/install.sh | sudo bash
# 1. Client key (separated storage)
mkdir -p ~/.config/restic
openssl rand -hex 16 > ~/.config/restic/password
chmod 600 ~/.config/restic/password
op item create "restic-backup-key" --field text=password=$(cat ~/.config/restic/password)
# 2. Primary bucket (R2 example)
R2_BUCKET="vps-backup"
mc alias set r2 R2_ACCESS_KEY_ID R2_SECRET_KEY https://<uid>.r2.cloudflarestorage.com
mc mb -p private r2/$R2_BUCKET
mc ilm rule add --bucket $R2_BUCKET --transition 30 --expire 365 # optional lifecycle
mc api put-bucket-object-lock-configuration --bucket $R2_BUCKET \
--retention-mode GOVCOMPLIANCE --retention-period 90d
# 3. Offsite DR bucket + CRR
mc mb r2/vps-backup-dr
mc replicate enable r2/$R2_BUCKET r2/vps-backup-dr
# 4. Initialize restic
export RESTIC_REPOSITORY="s3:r2:..." # restic native s3 backend, R2 endpoint
export RESTIC_PASSWORD_FILE=~/.con...stic -r $RESTIC_REPOSITORY init
restic -r $RESTIC_REPOSITORY check
# 5. First full backup
restic -r $RESTIC_REPOSITORY backup /etc /home /var/lib/docker \
--exclude-file /etc/restic/excludes.d/*
# 6. Retention policy
cat > /etc/restic/retain.conf <<EOF
restic -r $RESTIC_REPOSITORY forget --keep-daily 7 --keep-weekly 4 \
--keep-monthly 12 --keep-yearly 3 --prune
EOF
chmod +x /etc/restic/retain.conf
# 7. cron consolidation
cat > /etc/cron.d/restic-backup <<EOF
# Daily 02:30 incremental backup
30 2 * * * root /etc/restic/backups-job.sh >> /var/log/restic-backup.log 2>&1
# Monthly 1st, 03:00 retention
0 3 1 * * root /etc/restic/retain.conf >> /var/log/restic-retention.log 2>&1
# Monthly 1st, 03:30 recoverability drill
30 3 1 * * root /usr/local/bin/backup-drill.sh >> /var/log/backup-drill.log 2>&1
EOF
For restic’s native s3 backend against R2, set:
RESTIC_REPOSITORY=s3:https://<uid>.r2.cloudflarestorage.com/vps-backup RESTIC_S3_PROVIDER=custom RESTIC_S3_ACCESS_KEY / RESTIC_S3_SECRET_KEY / RESTIC_S3_REGION=auto
6. Monitoring and Alerting
| Metric | Tool | Alert Condition |
|---|---|---|
| Backup job exit code | Prometheus + restic-exporter | 2 consecutive non-zero exits |
| Repository size growth | restic stats | >50% growth in 7 days |
| Drill pass status | script → /var/log + Uptime Kuma text check | drill failure |
| CRR replication lag | S3 console / mc query | lag > 24h |
| Object lock status | aws s3api get-bucket-object-lock-configuration | missing |
One-liner restic-exporter:
docker run -d --name restic-exporter \
-v ~/.config/restic:/root/.config/restic:ro \
-e RESTIC_REPOSITORY=s3:... \
quay.io/restic/restic-exporter:latest
7. The Savings Ledger (1C2G VPS, 100GB of Data)
| Item | DIY | Managed SaaS (Pico / ServerBackup) |
|---|---|---|
| Backup tool | Restic (open source, $0) | subscription |
| Primary bucket storage | R2 $1.5/month | included in subscription |
| DR bucket + CRR | R2 $1.5/month | often not offered offsite |
| Egress | R2 $0 | ~$0.12/GB metered |
| Key management | 1Password (home sub amortized) | platform-hosted |
| Total / month | ~$3 | $15–30 |
You save $12–27/month, $144–324/year. More importantly: data sovereignty stays with you, migration off a SaaS vendor is trivial, and you’re never vendor-locked.
8. Common Pitfalls and Troubleshooting
restic checkreportsunexpected lock: a previous backup process was killed. Runrestic unlock, then retry.- CRR lag > 24h: R2’s CRR is best-effort. For very large objects (>10GB Docker layers), switch to active
mc mirrorsync instead. - Object lock 90-day window elapses without review:
put-bucket-object-lock-configurationcan tighten, not loosen. Plan lifecycle rules + deletion policy ahead of time; don’t just “unlock” it. - Key in 1Password gets synced to a compromised Mac: use 1Password’s offline PIN + separated key file, or age-encrypt a copy stored on the offsite VPS.
- Drill “passes” every time: check that the
diff -rqand file-count guard actually ran. Add the 100-file minimum guard shown in §4 — a drill that restores an empty tree is a failure, not a pass.
Conclusion: Backups Are Insurance; Drills Are Verifying the Policy
Restic + S3 + 6 layers + 2-1-1 + monthly drills form a complete data insurance system. The question is not “did you back it up?” but “can you restore within 30 minutes when it actually breaks?” Once this pipeline is automated, your VPS data-loss risk is an engineering near-zero, at about $3/month.
Next step: post the 6-layer checklist on the front page of your ops wiki. Review it quarterly. Put drill logs into team OKRs. Recoverability of the backup system is the insurance policy you signed for yourself — after signing, actually verify the policy covers you.
