Overview
A backup must live off the box and be restorable. Provider backups on the same account (hostinger-vps covers what Hostinger offers) and local snapshots do not survive account loss, ransomware, or a bad rm -rf that you replicate. restic gives encrypted, deduplicated backups to S3, Backblaze B2, SFTP, cloudflare-r2, or any rclone backend. Targets and drills in general: disaster-recovery.
Initialize the repository and store the passphrase twice
apt install -y restic
install -m 600 /dev/null /etc/restic.env
cat >/etc/restic.env <<'EOF'
B2_ACCOUNT_ID=<id>
B2_ACCOUNT_KEY=<application-key>
RESTIC_REPOSITORY=b2:<bucket>:/vps-backups
RESTIC_PASSWORD=<passphrase>
EOF
set -a; . /etc/restic.env; set +a; restic initKeep the passphrase in a password manager and in a second location off the VPS. Without it the repository is unreadable. Keep credentials in the root-only env file, not in the script or shell history. Use a bucket-scoped application key.
Write the backup script
/usr/local/bin/backup.sh (chmod 750, owned by root):
#!/bin/bash
set -euo pipefail
set -a; . /etc/restic.env; set +a
DUMP_DIR=/var/backups/db # mode 700, root only
install -d -m 700 "$DUMP_DIR"
sudo -u postgres pg_dump -Fc mydb > "$DUMP_DIR/mydb.dump"
restic backup /srv/app /etc/app /etc/caddy "$DUMP_DIR" \
--exclude /srv/app/node_modules --exclude /srv/app/.git
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 3 --prune
rm -f "$DUMP_DIR/mydb.dump"
curl -fsS -m 10 "$HEALTHCHECK_URL" >/dev/null # dead-man's switch ping, only on successBack up the app directory, config not in git, and database dumps; exclude what a build recreates. Schedule it as root in a quiet window:
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Define HEALTHCHECK_URL in /etc/restic.env. A monitor such as Healthchecks.io that alerts when the ping stops catches cron failures, rotated credentials, and network problems (disaster-recovery).
Match retention to recovery needs
--keep-daily 7 --keep-weekly 4 --keep-monthly 3 keeps fine-grained history near the present and a three-month tail. Add --keep-yearly for regulated data. forget only unlists snapshots; --prune (or a separate restic prune) deletes the unreferenced data.
Verify the repository and drill restores
restic check --read-data-subset=5% # weekly; samples pack files, cheaper than --read-data
restic snapshots
restic restore latest --target /tmp/restore-test
pg_restore -d mydb_test /tmp/restore-test/var/backups/db/mydb.dumprestic check without options validates structure; --read-data-subset reads a random sample of the data (n/t, a percentage, or a size) and --read-data downloads everything, which can cost real bandwidth. Run a full restore drill at least quarterly: restore to a scratch host, load the dump into a scratch Postgres, confirm row counts and application-critical data, and record the date and elapsed time in the runbook.