Offsite Backup to Oracle Cloud with restic
A short, no-dependency way to get an encrypted offsite backup onto an Oracle Cloud Always Free instance — replaces a Synology HyperBackup setup that needed a working DSM instance just to restore.
I used to run this leg through Synology HyperBackup, relaying to a plain rsync daemon on the Oracle VPS. It worked, but restoring from it needed a working DSM instance — real or Virtual DSM — just to run HyperBackup’s own restore wizard. That’s a dependency I didn’t want on my only offsite copy. Replaced it with restic: open repository format, restorable with nothing but the binary and a password, on any OS.
Note
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-backupsudo useradd -g restic-backup -s /usr/sbin/nologin -d /srv/restic-repo -M restic-backup
sudo mkdir -p /srv/restic-repo/datasudo chown root:root /srv/restic-reposudo chmod 755 /srv/restic-reposudo chown restic-backup:restic-backup /srv/restic-repo/datasudo chmod 700 /srv/restic-repo/data
sudo mkdir -p /srv/restic-repo/.sshecho "restrict $(cat id_ed25519_restic.pub)" | sudo tee /srv/restic-repo/.ssh/authorized_keyssudo 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, and only the data/ subdirectory one level down belongs to the
backup user.
Source: install restic, init the repo
apt install -y restic
ssh-keygen -t ed25519 -f /root/.ssh/restic-to-oracle -N ""# copy the .pub to the VPS step above
cat >> /root/.ssh/config <<'EOF'Host oracle-vps-restic HostName <vps-ip-or-tailscale-ip> User restic-backup IdentityFile /root/.ssh/restic-to-oracle StrictHostKeyChecking accept-new BatchMode yesEOFchmod 600 /root/.ssh/config
openssl rand -base64 32 > /root/.restic-oracle-passwordchmod 600 /root/.restic-oracle-password# save this password in a password manager too — lose it, lose the repo
export RESTIC_REPOSITORY="sftp:oracle-vps-restic:/data"export RESTIC_PASSWORD_FILE="/root/.restic-oracle-password"restic initThe backup script
#!/bin/bashset -euo pipefailexport 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 nightlyrestic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 3 --prunerestic 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/passwordrestic snapshotsrestic 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:
#!/bin/bashset -uo pipefailexport RESTIC_REPOSITORY="sftp:oracle-vps-restic:/data"export RESTIC_PASSWORD_FILE="/root/.restic-oracle-password"
HC_URL="https://hc-ping.com/your-drill-check-id"DRILL_DIR="/tmp/restic-restore-drill-$$"trap 'rm -rf "$DRILL_DIR"' EXIT
PATH_TO_CHECK="/path/to/backup-one"mkdir -p "$DRILL_DIR"restic restore latest --include "$PATH_TO_CHECK" --target "$DRILL_DIR"
live=$(find "$PATH_TO_CHECK" -type f -exec sha256sum {} \; | awk '{print $1}' | sort | sha256sum)restored=$(find "$DRILL_DIR$PATH_TO_CHECK" -type f -exec sha256sum {} \; | awk '{print $1}' | sort | sha256sum)
if [ "$live" = "$restored" ]; then curl -fsS -m 10 --retry 3 -o /dev/null "$HC_URL" || trueelse curl -fsS -m 10 --retry 3 -o /dev/null "$HC_URL/fail" || true exit 1fiHash 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.