This assumes a source host you control directly (mine’s a Proxmox host, but any Linux box works) and a destination VPS reachable over SSH — Tailscale or a public IP, doesn’t matter.
Destination: a locked-down receiving user#
On the VPS, create a user that can only SFTP into one directory — no shell, no port forwarding, nothing else:
sudo groupadd restic-backup
sudo useradd -g restic-backup -s /usr/sbin/nologin -d /srv/restic-repo -M restic-backup
sudo mkdir -p /srv/restic-repo/data
sudo chown root:root /srv/restic-repo
sudo chmod 755 /srv/restic-repo
sudo chown restic-backup:restic-backup /srv/restic-repo/data
sudo chmod 700 /srv/restic-repo/data
# restic-to-oracle.pub comes from the source host, generated in the next section
sudo mkdir -p /srv/restic-repo/.ssh
echo "restrict $(cat restic-to-oracle.pub)" | sudo tee /srv/restic-repo/.ssh/authorized_keys
sudo chmod 644 /srv/restic-repo/.ssh/authorized_keysThen restrict the account in sshd_config:
Match User restic-backup
ChrootDirectory /srv/restic-repo
ForceCommand internal-sftp
AllowTcpForwarding no
X11Forwarding no
PermitTTY no
PasswordAuthentication nosudo systemctl reload sshChrootDirectory requires every path component up to it to be root-owned and not world-writable — that’s why /srv/restic-repo itself is 755 root:root, and only the data/ subdirectory one level down belongs to the backup user.
Source: install restic, init the repo#
| |
The backup script#
#!/bin/bash
set -euo pipefail
export RESTIC_REPOSITORY="sftp:oracle-vps-restic:/data"
export RESTIC_PASSWORD_FILE="/root/.restic-oracle-password"
HC_URL="https://hc-ping.com/your-check-id"
trap '[ -n "$HC_URL" ] && curl -fsS -m 10 --retry 3 -o /dev/null "$HC_URL/fail" || true' ERR
restic backup /path/to/backup-one /path/to/backup-two --tag nightly
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 3 --prune
restic check
[ -n "$HC_URL" ] && curl -fsS -m 10 --retry 3 -o /dev/null "$HC_URL" || trueCron it nightly, wherever fits your other jobs. The healthchecks.io ping means a silent failure actually gets noticed instead of sitting undiscovered until the day you need the backup.
Restoring#
From any machine with restic, the password, and network access:
export RESTIC_REPOSITORY="sftp:restic-backup@<vps-ip>:/data"
export RESTIC_PASSWORD_FILE=/path/to/saved/password
restic snapshots
restic restore latest --target /tmp/restoredNo DSM, no restore wizard, no vendor tool.
Prove it, don’t assume it#
restic check only verifies the repository’s internal consistency — it doesn’t prove the content inside is actually what you think it is. A monthly cron that restores a small real path and diffs it against the live source catches the gap:
| |
Hash the file contents only (awk '{print $1}') — comparing full sha256sum output fails every time even on identical files, since the restored copy always lands under a different parent path than the live source.
That last part is the whole point: a backup nobody’s ever restored from is a hope, not a backup.