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 restic

Pick 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 init

Store 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/.git

Exclude 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.sh

Schedule 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>&1

Daily 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-test

For the database, load the dump into a test Postgres instance:

gunzip < /tmp/restore-test/tmp/mydb_*.sql.gz | psql -U postgres mydb_test

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