↓ Skip to main content

Offsite Backup to Oracle Cloud with restic

·4 mins
Table of Contents
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-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_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
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:root, and only the data/ subdirectory one level down belongs to the backup user.

Source: install restic, init the repo
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apt install -y restic

ssh-keygen -t ed25519 -f /root/.ssh/restic-to-oracle -N ""
# copy /root/.ssh/restic-to-oracle.pub to the VPS, into the authorized_keys 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:

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/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.