Fall 2026brianmars.comBuilt at EGBT Technologies
ReferenceBackups

restic to SFTP and R2

Encrypted daily backups to a machine at home and to object storage off-site, run as root by a systemd timer, with a tested restore.

Names and addresses are placeholders. The list says what each one stands for.

What it does here

Three copies, two kinds of storage, one off-site: the live data on the data disk, a fast copy on a home machine over SFTP, and an encrypted copy in Cloudflare R2. restic encrypts before it uploads, so neither target sees anything but ciphertext.

From the manual

Canonical setup

Initialise a repository per target with a password, back up, apply a retention policy with forget --prune, and schedule it.

Install and make a password
sudo apt install -y restic
openssl rand -base64 32

Save that password in your password manager. Without it, the backups can't be read.

Upstream: restic: preparing a new repository.

Edited for this build

In this build

  1. Two repositories, one scriptOne target failing (the home machine asleep) doesn't stop the other.
  2. Runs as rootTo read the root-owned Forgejo data. So root's SSH key, not yours, goes on the backup host.
  3. Secrets in a root-only env file/root/restic-devbox.env, chmod 600.
  4. HOME set in the scriptsystemd runs without one.
  5. The Docker data root excludedImages and volumes there are large and can be recreated.
  6. Keep 7 daily, 4 weekly, 6 monthly
  7. Daily at 03:30With Persistent=true so a missed run catches up, and a random delay.
Root's key on the backup host
sudo test -f /root/.ssh/id_ed25519 || sudo ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ''
sudo ssh-copy-id -i /root/.ssh/id_ed25519.pub backupuser@backup-host.local
sudo ssh -o BatchMode=yes backupuser@backup-host.local 'echo ok'   # must print ok, no prompt

On the backup host, create /srv/backups/devbox-restic owned by backupuser. For R2, create a bucket and an Object Read and Write token, and note its access key, secret and account ID.

/root/restic-devbox.env (chmod 600)
export RESTIC_PASSWORD='...'
export AWS_ACCESS_KEY_ID='...'
export AWS_SECRET_ACCESS_KEY='...'
export SFTP_REPO='sftp:backupuser@backup-host.local:/srv/backups/devbox-restic'
export R2_REPO='s3:https://<account-id>.r2.cloudflarestorage.com/devbox-backups'
Initialise both
sudo bash -c 'source /root/restic-devbox.env && restic -r "$SFTP_REPO" init'
sudo bash -c 'source /root/restic-devbox.env && restic -r "$R2_REPO" init'
/usr/local/bin/devbox-backup.sh (chmod 700)
#!/usr/bin/env bash
set -uo pipefail
export HOME=/root
source /root/restic-devbox.env
status=0
for REPO in "$SFTP_REPO" "$R2_REPO"; do
  echo "== backing up to $REPO =="
  if restic -r "$REPO" backup /srv/stacks /srv/data \
        --exclude /srv/data/docker --tag devbox; then
    restic -r "$REPO" forget --tag devbox \
      --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune || status=1
  else
    echo "!! backup to $REPO FAILED" >&2
    status=1
  fi
done
exit $status
/etc/systemd/system/devbox-backup.service and .timer
sudo tee /etc/systemd/system/devbox-backup.service >/dev/null <<'EOF'
[Unit]
Description=devbox restic backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/devbox-backup.sh
EOF

sudo tee /etc/systemd/system/devbox-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Daily devbox backup

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=15m

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now devbox-backup.timer
sudo systemctl start devbox-backup.service
sudo journalctl -u devbox-backup.service -f
Where it breaks

Seams

Where this meets the rest of the build. Each one leads with what you will actually see.

root's key meets SFTP

SymptomPermission denied (publickey,password)

The job runs as root, so it offers root's key. Yours being authorised on the backup host doesn't help. Copy root's public key there.

A plain ssh test meets a password prompt

Symptomthe test worked, and the timer still fails

A plain ssh can succeed by asking for a password, which a timer can't answer. Test with -o BatchMode=yes: it fails instead of prompting.

systemd meets HOME

Symptomssh can't find its key and restic can't find its cache

Services run with no $HOME. export HOME=/root at the top of the script fixes both.

A backup meets its first restore

Symptomyou find out the backup was broken when you need it

An untested backup is a guess. Restore one file from the off-site copy now, as in Verify.

Verify

Restore one file from R2
sudo bash -c 'source /root/restic-devbox.env && export HOME=/root && restic -r "$R2_REPO" restore latest --target /tmp/restore-test --include /srv/stacks/forgejo/docker-compose.yml'
sudo cat /tmp/restore-test/srv/stacks/forgejo/docker-compose.yml && sudo rm -rf /tmp/restore-test
Corrections and updates
  1. 17 Oct 2026First written.