Overview
Local snapshots are not backups. A disk failure, ransomware event, or accidental rm -rf on the VPS destroys both the data and the snapshot. Backups must be off-box and tested with a restore drill. restic is the right tool: encrypted, deduplicated, and able to push to S3, Backblaze B2, SFTP, or any rclone-supported backend. This page covers setup, scheduling, retention, and the restore drill you must run at least once. See hostinger-vps for the full VPS context and postgres for database dump commands.
Install restic and initialize a repository
apt install -y resticPick a backend. Backblaze B2 is inexpensive; AWS S3 or cloudflare-r2 also work. For B2:
export B2_ACCOUNT_ID=your_account_id
export B2_ACCOUNT_KEY=your_application_key
export RESTIC_REPOSITORY=b2:your-bucket-name:/vps-backups
export RESTIC_PASSWORD=your_strong_passphrase
restic initStore the passphrase in a password manager and in a second location that is not on the VPS. Without it, the backup is permanently unreadable. The repository password encrypts every snapshot; restic itself cannot decrypt without it.
Define what to back up
Back up the app directory, database dumps, and any config files that are not in git.
# Dump Postgres before backup
pg_dump -U postgres mydb | gzip > /tmp/mydb_$(date +%Y%m%d).sql.gz
# Run restic backup
restic backup \
/srv/app \
/etc/app \
/etc/caddy \
/tmp/mydb_$(date +%Y%m%d).sql.gz \
--exclude /srv/app/node_modules \
--exclude /srv/app/.gitExclude large directories that can be reconstructed (node_modules, .git, build output). Including them wastes storage and slows backup without improving restorability.
Write the backup script and cron entry
Create /usr/local/bin/backup.sh:
#!/bin/bash
set -euo pipefail
export B2_ACCOUNT_ID=your_account_id
export B2_ACCOUNT_KEY=your_application_key
export RESTIC_REPOSITORY=b2:your-bucket-name:/vps-backups
export RESTIC_PASSWORD=your_strong_passphrase
# Database dump
DUMP_FILE="/tmp/mydb_$(date +%Y%m%d_%H%M).sql.gz"
pg_dump -U postgres mydb | gzip > "$DUMP_FILE"
# Backup
restic backup \
/srv/app \
/etc/app \
/etc/caddy \
"$DUMP_FILE" \
--exclude /srv/app/node_modules \
--exclude /srv/app/.git
# Apply retention policy
restic forget \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 3 \
--prune
# Clean up dump
rm -f "$DUMP_FILE"Make it executable:
chmod 750 /usr/local/bin/backup.sh
chown root:root /usr/local/bin/backup.shSchedule with cron. Run as root so it can read all target directories:
crontab -e -u root
# Add:
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1Daily at 03:00. Adjust to a low-traffic window for your timezone.
Set a retention policy that matches recovery objectives
The restic forget --prune command applies the policy and removes data not referenced by kept snapshots.
--keep-daily 7 # One snapshot per day for the last 7 days
--keep-weekly 4 # One per week for the last 4 weeks
--keep-monthly 3 # One per month for the last 3 months
This gives a 3-month window with fine granularity near the present. For regulated or high-value data, extend --keep-monthly and add --keep-yearly.
Run --prune in the same forget call or separately with restic prune. Without it, old snapshots are forgotten (unlisted) but the underlying data is not deleted.
Run a restore drill before you need it
A backup you have not restored is an assumption. Schedule a restore drill quarterly at minimum.
# List snapshots
restic snapshots
# Restore the latest snapshot to a staging directory
restic restore latest --target /tmp/restore-test
# Verify key files exist and are intact
ls /tmp/restore-test/srv/app
ls /tmp/restore-test/tmp/*.sql.gz
# Restore a specific snapshot
restic restore abc123de --target /tmp/restore-testFor the database, load the dump into a test Postgres instance:
gunzip < /tmp/restore-test/tmp/mydb_*.sql.gz | psql -U postgres mydb_testConfirm row counts and spot-check application-critical data. Document the result and the date in a runbook.
Monitor backup health
Check that cron ran and that the last snapshot is recent:
restic snapshots --last --json | python3 -c "
import json, sys, datetime
snaps = json.load(sys.stdin)
if not snaps:
print('NO SNAPSHOTS'); sys.exit(1)
ts = datetime.datetime.fromisoformat(snaps[-1]['time'].replace('Z', '+00:00'))
age = (datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds() / 3600
print(f'Last snapshot: {ts} ({age:.1f}h ago)')
if age > 26:
print('WARNING: backup is stale'); sys.exit(1)
"Wire this check into your monitoring system. An alert on a stale backup catches cron failures, credential rotation issues, and network disruptions before a disaster exposes them.