What you will need
1. Pick what to back up, and what to skip
LOCATIONS=("$HOME/projects" "$HOME/scripts" "$HOME/notes")
EXCLUDES=(
# skip regenerable build artifacts
--exclude '**/node_modules'
--exclude '**/vendor'
--exclude '**/target/debug'
--exclude '**/target/release'
--exclude '**/tmp'
--exclude '**/log'
--exclude '**/logs'
# plus specific directories handled elsewhere (already on a remote,
# huge media, or genuinely disposable)
--exclude '/home/*/projects/some-huge-thing'
)
2. Configure an rclone remote
Point rclone at whatever cloud storage you use. The interactive prompt walks through the provider list and any OAuth step:
rclone config # follow the prompts, give the remote a name
rclone listremotes # confirm it exists
Whatever name you pick becomes the <remote> in restic's
rclone:<remote>:<path>
3. Keep two repositories
Local for quick access, remote for secure backup.
NAME="main"
LOCAL_REPO="$HOME/backups/$NAME"
REMOTE_REPO="rclone:myremote:/backup/$NAME"
4. Make the script idempotent
ensure_repo() {
local repo="$1"
restic -r "$repo" cat config >/dev/null 2>&1
case "$?" in
0) echo "Repository exists: $repo" ;;
10) echo "Initializing: $repo"; restic -r "$repo" init ;;
*) echo "Cannot access repository $repo" >&2; return 1 ;;
esac
}
Now a fresh machine bootstraps itself on first run, and a real failure is loud instead of being silently "fixed" by re-initializing an empty repo over your backups.
5. Handle stale locks
An interrupted run (laptop suspended mid-backup) leaves a lock behind and every later run fails. So unlock before each backup:
for repo in "$LOCAL_REPO" "$REMOTE_REPO"; do
ensure_repo "$repo" || continue
restic -r "$repo" unlock
restic -r "$repo" backup "${LOCATIONS[@]}" "${EXCLUDES[@]}"
done
6. Keep the password out of the script
export RESTIC_PASSWORD_FILE="$HOME/.config/restic/password"
RESTIC_PASSWORD_FILE is the default environment variable that tells restic which password to use to secure your encrypted backup.
7. The whole script
Put together, backup.sh:
#!/bin/bash
export RESTIC_PASSWORD_FILE="$HOME/.config/restic/password"
NAME="main"
LOCAL_REPO="$HOME/backups/$NAME"
REMOTE_REPO="rclone:myremote:/backup/$NAME"
LOCATIONS=("$HOME/projects" "$HOME/scripts" "$HOME/notes")
EXCLUDES=(
# skip regenerable build artifacts
--exclude '**/node_modules'
--exclude '**/vendor'
--exclude '**/target/debug'
--exclude '**/target/release'
--exclude '**/tmp'
--exclude '**/log'
--exclude '**/logs'
# plus specific directories handled elsewhere (already on a remote,
# huge media, or genuinely disposable)
--exclude '/home/*/projects/some-huge-thing'
)
ensure_repo() {
local repo="$1"
restic -r "$repo" cat config >/dev/null 2>&1
case "$?" in
0) echo "Repository exists: $repo" ;;
10) echo "Initializing: $repo"; restic -r "$repo" init ;;
*) echo "Cannot access repository $repo" >&2; return 1 ;;
esac
}
for repo in "$LOCAL_REPO" "$REMOTE_REPO"; do
echo "Backing up to $repo"
ensure_repo "$repo" || continue
restic -r "$repo" unlock
restic -r "$repo" backup "${LOCATIONS[@]}" "${EXCLUDES[@]}"
done
Make it executable with chmod +x backup.sh.
8. Schedule it with a systemd user timer
A user timer beats cron here: it gets journal logs, it has Persistent=true (missed runs fire on next boot), and it can wait for the network.
~/.config/systemd/user/backup.service:
[Unit]
Description=Daily backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/bash %h/path/to/backup.sh
KillMode=process
~/.config/systemd/user/backup.timer:
[Unit]
Description=Run daily backup
[Timer]
OnCalendar=*-*-* 00:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
Install and enable it
mkdir -p ~/.config/systemd/user/
cp ./backup.service ./backup.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now backup.timer
systemctl --user list-timers | grep backup
9. Verify restores
A backup you have never restored from is a hypothesis. And the one to test is the remote, the local cache working proves nothing about the copy you would actually need in a real loss.
restic can mount a repository as a filesystem, so browsing history is just cd:
mkdir -p /mnt/restic
restic -r "$REMOTE_REPO" mount /mnt/restic
# then: ls /mnt/restic/snapshots/latest/
Mounting the remote is slow, and that's fine, you're testing that it's there and intact, not benchmarking it. Do this occasionally on the remote; use the local repo for everyday "oops, undelete that" work.
Integrity check on the remote, the one that matters:
restic -r "$REMOTE_REPO" check # structure intact?
restic -r "$REMOTE_REPO" check --read-data # actually re-read every blob (slow)
Retention runs per-repository, so prune both, and they don't have to match. Keeping less locally is reasonable; it's only a cache:
restic -r "$LOCAL_REPO" forget --keep-daily 7 --keep-weekly 4 --prune
restic -r "$REMOTE_REPO" forget --keep-daily 14 --keep-weekly 8 \
--keep-monthly 12 --prune
You can also add this to the script if you like.