Offsite Backup to Oracle Cloud with restic

#storage#homelab#backup

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:

Terminal window
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
sudo mkdir -p /srv/restic-repo/.ssh
echo "restrict $(cat id_ed25519_restic.pub)" | sudo tee /srv/restic-repo/.ssh/authorized_keys
sudo chmod 644 /srv/restic-repo/.ssh/authorized_keys

Then restrict the account in sshd_config:

Match User restic-backup
ChrootDirectory /srv/restic-repo
ForceCommand internal-sftp
AllowTcpForwarding no
X11Forwarding no
PermitTTY no
PasswordAuthentication no
Terminal window
sudo systemctl reload ssh

ChrootDirectory 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

Terminal window
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 yes
EOF
chmod 600 /root/.ssh/config
openssl rand -base64 32 > /root/.restic-oracle-password
chmod 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 init

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" || true

Cron 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:

Terminal window
export RESTIC_REPOSITORY="sftp:restic-backup@<vps-ip>:/data"
export RESTIC_PASSWORD_FILE=/path/to/saved/password
restic snapshots
restic restore latest --target /tmp/restored

No 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/bash
set -uo pipefail
export 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" || true
else
curl -fsS -m 10 --retry 3 -o /dev/null "$HC_URL/fail" || true
exit 1
fi

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.