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.
Canonical setup
Initialise a repository per target with a password, back up, apply a retention policy with forget --prune, and schedule it.
sudo apt install -y restic
openssl rand -base64 32Save that password in your password manager. Without it, the backups can't be read.
Upstream: restic: preparing a new repository.
In this build
- Two repositories, one scriptOne target failing (the home machine asleep) doesn't stop the other.
- Runs as rootTo read the root-owned Forgejo data. So root's SSH key, not yours, goes on the backup host.
- Secrets in a root-only env file
/root/restic-devbox.env, chmod 600. - HOME set in the scriptsystemd runs without one.
- The Docker data root excludedImages and volumes there are large and can be recreated.
- Keep 7 daily, 4 weekly, 6 monthly
- Daily at 03:30With
Persistent=trueso a missed run catches up, and a random delay.
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 promptOn 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.
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'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/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 $statussudo 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 -fSeams
Where this meets the rest of the build. Each one leads with what you will actually see.
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.
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.
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.
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
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- 17 Oct 2026First written.