Today I learned
Read about some cool stuff.
Automatic backups with restic
Sep 10, 2026

What you will need

  1. restic - deduplicating, encrypted, snapshot-based backup tool.
  2. rclone - "rsync for cloud storage".

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.

ul. Powstańców Warszawy 5
15-129 Białystok
+48 668 842 999
CONTACT US