Setup time: ~45 minutes with Claude Code assistance (not counting initial backup upload, which runs in the background and may take hours/days depending on your data size and internet speed).

A practical guide to backing up your computer to the cloud using restic and Backblaze B2. Your files are encrypted before leaving your machine, deduplicated to save space, and stored offsite for ~$6/TB/month.

Why This Setup?

  • Encrypted: Your data is encrypted with a password before upload. Backblaze can’t read it.
  • Deduplicated: Only changed portions of files are uploaded. A 10GB file with 1MB of changes = 1MB upload.
  • Versioned: Keep 30 days of snapshots. Accidentally delete something? Restore from yesterday.
  • Cheap: ~$6/TB/month. 100GB costs ~60 cents/month.
  • Cross-platform: Same tool works on Windows, macOS, and Linux.

What about OneDrive/iCloud/Google Drive?

Those sync services are great for access across devices, but they’re not proper backups:

  • Delete a file? It’s deleted everywhere.
  • Ransomware encrypts your files? Synced everywhere.
  • They don’t keep long version histories.

Use restic + B2 in addition to your sync service, not instead of it.


Overview

  1. Create a Backblaze B2 account and bucket
  2. Install restic
  3. Configure what to back up (and what to skip)
  4. Run your first backup
  5. Automate daily backups

Pick your OS: Windows 11 | macOS | Linux


Part 1: Backblaze B2 Setup (All Platforms)

1.1 Create Account

  1. Go to backblaze.com/b2
  2. Sign up for B2 Cloud Storage (not “Personal Backup” โ€” that’s a different product)
  3. Verify your email

1.2 Create Bucket

  1. Buckets โ†’ Create a Bucket

  2. Settings:

    • Bucket Name: mybackup-abc123 (must be globally unique โ€” add random characters)
    • Files in Bucket: Private
    • Default Encryption: Disable (restic handles encryption client-side)
    • Object Lock: Disable
  3. Note the S3 Endpoint shown (e.g., s3.us-west-004.backblazeb2.com)

1.3 Set Lifecycle Policy

  1. Click your bucket โ†’ Lifecycle Settings
  2. Set to: “Keep only the last version of the file”

Why this matters: When using B2’s S3-compatible API, restic “hides” old file versions rather than deleting them. B2 keeps hidden versions indefinitely by default, which wastes storage. This lifecycle rule automatically deletes hidden versions after one day, freeing up space. (Restic handles its own versioning via snapshots โ€” B2’s file versioning is redundant.)

1.4 Create Application Key (S3-Compatible)

We need S3-compatible credentials. See Backblaze’s S3-compatible API documentation for details.

  1. App Keys โ†’ Add a New Application Key

  2. Settings:

    • Name: restic-backup
    • Allow access to Bucket(s): Select your specific bucket
    • Type of Access: Read and Write
    • Allow List All Bucket Names: Unchecked
  3. SAVE THESE IMMEDIATELY (shown only once):

    • keyID โ€” this becomes your AWS_ACCESS_KEY_ID
    • applicationKey โ€” this becomes your AWS_SECRET_ACCESS_KEY

Store these in your password manager. You’ll use them with AWS environment variable names (that’s how S3-compatible APIs work).

Why S3-Compatible Mode?

Backblaze B2 offers two connection methods: native B2 (b2:bucket) and S3-compatible (s3:endpoint/bucket). This guide uses S3-compatible mode because restic’s documentation recommends it:

“Due to issues with error handling in the current B2 library that restic uses, the recommended way to utilise Backblaze B2 is by using its S3-compatible API.”

The S3 mode has better error handling and is more reliable for automated backups.


Windows 11

2.1 Install Restic

Option A: Scoop

# Install Scoop if you don't have it
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression

# Install restic
scoop install restic

Option B: WinGet (recommended for system-wide backups)

winget install --exact --id restic.restic --scope Machine

This installs to %ProgramFiles% and adds restic to your PATH.

Option C: Manual download

  1. Download from github.com/restic/restic/releases
  2. Get the restic_X.X.X_windows_amd64.zip file
  3. Extract restic.exe to C:\Program Files\restic\
  4. Add C:\Program Files\restic\ to your PATH

2.2 Create Configuration

Create a folder for your backup config:

mkdir "$env:USERPROFILE\.config\restic"

Create the environment file %USERPROFILE%\.config\restic\env.ps1:

$env:RESTIC_REPOSITORY = "s3:s3.us-west-004.backblazeb2.com/YOUR-BUCKET-NAME"
$env:RESTIC_PASSWORD = "YOUR_SECURE_PASSWORD_HERE"
$env:AWS_ACCESS_KEY_ID = "YOUR_B2_KEY_ID"
$env:AWS_SECRET_ACCESS_KEY = "YOUR_B2_APPLICATION_KEY"

Replace:

  • us-west-004 with your region from the bucket page
  • YOUR-BUCKET-NAME with your actual bucket name
  • YOUR_SECURE_PASSWORD_HERE with a strong password (this encrypts your backups โ€” save it in your password manager)
  • The AWS variables with your B2 credentials

Restrict the file to your user account. The file now contains your repo password and B2 keys in plaintext โ€” anyone able to read it has full access to your backups. Remove inherited permissions and grant only your user:

icacls "$env:USERPROFILE\.config\restic\env.ps1" /inheritance:r /grant:r "$($env:USERNAME):(R,W)"

2.3 Create Exclude File

Create %USERPROFILE%\.config\restic\excludes.txt:

# Windows system files
$RECYCLE.BIN
System Volume Information
pagefile.sys
hiberfil.sys
swapfile.sys
*.tmp
*.temp
~$*

# Caches
AppData\Local\Temp
AppData\Local\Microsoft\Windows\INetCache
AppData\Local\Microsoft\Windows\Explorer\thumbcache*
AppData\Local\Packages\*\LocalCache
AppData\Local\Packages\*\TempState

# Browser caches (bookmarks/passwords ARE backed up)
AppData\Local\Google\Chrome\User Data\*\Cache
AppData\Local\Google\Chrome\User Data\*\Code Cache
AppData\Local\Google\Chrome\User Data\*\GPUCache
AppData\Local\BraveSoftware\Brave-Browser\User Data\*\Cache
AppData\Local\BraveSoftware\Brave-Browser\User Data\*\Code Cache
AppData\Local\Microsoft\Edge\User Data\*\Cache
AppData\Local\Mozilla\Firefox\Profiles\*\cache2

# Development
node_modules
.venv
__pycache__
*.pyc

# Large app caches
AppData\Local\Steam\steamapps
AppData\Local\Discord\Cache
AppData\Roaming\Spotify\Data

# OneDrive (already in cloud)
OneDrive

2.4 Initialise Repository

Open PowerShell:

# Load environment
. "$env:USERPROFILE\.config\restic\env.ps1"

# Initialise (one-time setup)
restic init

You’ll see a repository ID โ€” save this alongside your password.

2.5 First Backup

Recommended: Exclude large files

Add --exclude-larger-than 300M to skip files over 300MB. Large files (videos, ISOs, game assets) are often replaceable and expensive to store in the cloud. Adjust the threshold to your needs.

restic backup $env:USERPROFILE --exclude-file="$env:USERPROFILE\.config\restic\excludes.txt" --exclude-larger-than 300M --verbose

This single flag can dramatically reduce your backup size and B2 costs.

Know what this skips. A 300MB threshold silently excludes most 4K video clips, many stitched panoramas, Lightroom catalogues, and Photos/Aperture libraries (which are packages that often run tens of gigabytes). If any of those are irreplaceable to you, raise the threshold or back them up separately โ€” the backup will otherwise finish cleanly without them and you won’t notice they’re missing until you need them.

Optional: Test with a dry run first

. "$env:USERPROFILE\.config\restic\env.ps1"
restic backup $env:USERPROFILE --exclude-file="$env:USERPROFILE\.config\restic\excludes.txt" --dry-run -vv

This shows what would be backed up without uploading anything.

Run the actual backup:

# Load environment
. "$env:USERPROFILE\.config\restic\env.ps1"

# Back up your user folder
restic backup $env:USERPROFILE --exclude-file="$env:USERPROFILE\.config\restic\excludes.txt" --verbose

First backup may take hours depending on data size and upload speed. Subsequent backups only upload changes.

2.6 Automate with Task Scheduler

Create the backup script %USERPROFILE%\.config\restic\backup.ps1:

# Load environment
. "$env:USERPROFILE\.config\restic\env.ps1"

# Run backup (skip files >300MB)
restic backup $env:USERPROFILE --exclude-file="$env:USERPROFILE\.config\restic\excludes.txt" --exclude-larger-than 300M --quiet
$backupExit = $LASTEXITCODE

# Only forget old snapshots if backup succeeded.
# Exit 0 = clean success, exit 3 = partial (some files unreadable, snapshot still created).
# Any other code means no usable snapshot โ€” don't touch retention.
if ($backupExit -eq 0 -or $backupExit -eq 3) {
    restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6
} else {
    Write-Error "Backup failed with exit code $backupExit โ€” skipping forget."
    exit $backupExit
}

Note: this script runs forget (cheap metadata-only) but not prune. Pruning rewrites pack files and downloads data from B2 to do so โ€” running it daily is wasteful and can add meaningful egress cost on larger repos. Schedule pruning separately (see below).

Create the scheduled task:

  1. Open Task Scheduler (search in Start menu)
  2. Click Create Task (not “Create Basic Task”)
  3. General tab:
    • Name: Restic Backup
    • Check “Run whether user is logged on or not”
    • Check “Run with highest privileges”
  4. Triggers tab โ†’ New:
    • Begin the task: On a schedule
    • Daily, at 2:00 PM (or whenever your computer is usually on)
    • Check “Enabled”
  5. Actions tab โ†’ New:
    • Action: Start a program
    • Program: powershell.exe
    • Arguments: -ExecutionPolicy Bypass -File "%USERPROFILE%\.config\restic\backup.ps1"
  6. Conditions tab:
    • Uncheck “Start only if the computer is on AC power” (if you want laptop backups on battery)
  7. Settings tab:
    • Check “Run task as soon as possible after a scheduled start is missed”
  8. Click OK, enter your Windows password

Create a separate weekly prune script %USERPROFILE%\.config\restic\prune.ps1:

. "$env:USERPROFILE\.config\restic\env.ps1"
restic prune

Schedule this as a second task using the same Task Scheduler process, but weekly (e.g. Sunday 4:00 AM) instead of daily, pointing at prune.ps1.

2.7 Verify It’s Working

. "$env:USERPROFILE\.config\restic\env.ps1"
restic snapshots

You should see your backup snapshots listed.


macOS

3.1 Install Restic

Option A: Homebrew (recommended)

brew install restic

Option B: Manual download

# Download latest release
curl -LO https://github.com/restic/restic/releases/download/v0.17.3/restic_0.17.3_darwin_arm64.bz2

# Extract (use darwin_amd64 for Intel Macs)
bunzip2 restic_*.bz2
chmod +x restic_*
sudo mv restic_* /usr/local/bin/restic

3.2 Create Configuration

mkdir -p ~/.config/restic

Create ~/.config/restic/env.sh:

export RESTIC_REPOSITORY="s3:s3.us-west-004.backblazeb2.com/YOUR-BUCKET-NAME"
export RESTIC_PASSWORD="YOUR_SECURE_PASSWORD_HERE"
export AWS_ACCESS_KEY_ID="YOUR_B2_KEY_ID"
export AWS_SECRET_ACCESS_KEY="YOUR_B2_APPLICATION_KEY"

Secure the file:

chmod 600 ~/.config/restic/env.sh

Replace the placeholder values with your actual credentials.

3.3 Create Exclude File

Create ~/.config/restic/excludes.txt:

# macOS system
.Trash
.Spotlight-V100
.fseventsd
.DS_Store
.AppleDouble
.LSOverride
._*

# Caches
Library/Caches
Library/Logs
Library/Application Support/Google/Chrome/*/Cache
Library/Application Support/Google/Chrome/*/Code Cache
Library/Application Support/BraveSoftware/Brave-Browser/*/Cache
Library/Application Support/BraveSoftware/Brave-Browser/*/Code Cache
Library/Application Support/Firefox/Profiles/*/cache2
Library/Application Support/Slack/Cache
Library/Application Support/Spotify/PersistentCache
Library/Application Support/discord/Cache
Library/Containers/*/Data/Library/Caches

# Development
node_modules
.venv
__pycache__
*.pyc
.npm
.cargo

# Large/replaceable
Downloads/*.dmg
Downloads/*.iso
Movies
Music/Music/Media

# NOTE: Photos libraries (*.photoslibrary) are NOT excluded.
# If you use iCloud Photos and your library is fully synced in the cloud,
# uncomment the next line. Otherwise keep it commented โ€” restic will back up
# the package, which may be large but is often the most valuable data on the machine.
# *.photoslibrary

# iCloud (already in cloud)
Library/Mobile Documents

3.4 Initialise Repository

source ~/.config/restic/env.sh
restic init

Save the repository ID shown.

3.5 First Backup

Recommended: Exclude large files

Add --exclude-larger-than 300M to skip files over 300MB. Large files (videos, ISOs, game assets) are often replaceable and expensive to store in the cloud.

restic backup ~ --exclude-file="$HOME/.config/restic/excludes.txt" --exclude-larger-than 300M --verbose

This single flag can dramatically reduce your backup size and B2 costs.

Know what this skips. A 300MB threshold silently excludes most 4K video clips, many stitched panoramas, Lightroom catalogues, and Photos/Aperture libraries (which are packages that often run tens of gigabytes). If any of those are irreplaceable to you, raise the threshold or back them up separately โ€” the backup will otherwise finish cleanly without them and you won’t notice they’re missing until you need them.

Optional: Test with a dry run first

source ~/.config/restic/env.sh
restic backup ~ --exclude-file="$HOME/.config/restic/excludes.txt" --exclude-larger-than 300M --dry-run -vv

This shows what would be backed up without uploading anything.

Run the actual backup:

source ~/.config/restic/env.sh
restic backup ~ --exclude-file="$HOME/.config/restic/excludes.txt" --exclude-larger-than 300M --verbose

3.6 Automate with launchd

Create the backup script ~/.config/restic/backup.sh:

#!/bin/bash
set -euo pipefail

# Homebrew (and thus restic) lives outside launchd's default PATH,
# especially on Apple Silicon at /opt/homebrew/bin. Without this export
# the scheduled run fails silently with "restic: command not found".
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

# shellcheck source=/dev/null
source ~/.config/restic/env.sh

# Run backup (skip files >300MB). Don't exit on non-zero here โ€” we want to
# inspect the code and only skip forget on a hard failure.
set +e
restic backup ~ --exclude-file="$HOME/.config/restic/excludes.txt" --exclude-larger-than 300M --quiet
rc=$?
set -e

# Only forget old snapshots if backup succeeded.
# Exit 0 = clean success, exit 3 = partial (some files unreadable, snapshot still created).
# Any other code means no usable snapshot โ€” don't touch retention.
if [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ]; then
    restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6
else
    echo "Backup failed with exit code $rc โ€” skipping forget." >&2
    exit "$rc"
fi

Make it executable:

chmod +x ~/.config/restic/backup.sh

Note: this script runs forget (cheap metadata-only) but not prune. Pruning rewrites pack files and downloads data from B2 to do so โ€” running it daily is wasteful and can add meaningful egress cost on larger repos. Schedule pruning separately (see below).

Create the launchd plist ~/Library/LaunchAgents/com.restic.backup.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.restic.backup</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>-c</string>
        <string>$HOME/.config/restic/backup.sh</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>14</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/tmp/restic-backup.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/restic-backup.log</string>
</dict>
</plist>

Note: the plist parser itself doesn’t expand environment variables, which is why the script path is routed through bash -c โ€” bash expands $HOME at runtime. launchd sets HOME for LaunchAgents (user-scope), so this works without hardcoding your username.

Load it (modern macOS uses bootstrap/bootout; launchctl load is deprecated):

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.restic.backup.plist

To unload later: launchctl bootout gui/$(id -u)/com.restic.backup.

Create a separate weekly prune script ~/.config/restic/prune.sh:

#!/bin/bash
set -euo pipefail

# Same PATH fix as backup.sh โ€” launchd's default PATH doesn't include Homebrew.
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

# shellcheck source=/dev/null
source ~/.config/restic/env.sh

set +e
restic prune
rc=$?
set -e

if [ "$rc" -ne 0 ]; then
    echo "Prune failed with exit code $rc." >&2
    exit "$rc"
fi
chmod +x ~/.config/restic/prune.sh

Schedule it with a second LaunchAgent at ~/Library/LaunchAgents/com.restic.prune.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.restic.prune</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>-c</string>
        <string>$HOME/.config/restic/prune.sh</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Weekday</key>
        <integer>0</integer>
        <key>Hour</key>
        <integer>4</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/tmp/restic-prune.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/restic-prune.log</string>
</dict>
</plist>

Note the separate log path (/tmp/restic-prune.log) so prune output doesn’t collide with backup output in the same file.

Load it the same way: launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.restic.prune.plist.

3.7 Grant Full Disk Access

macOS sandboxes access to certain directories (~/Library/Mail, ~/Library/Messages, ~/Library/Safari, etc.) behind Full Disk Access (FDA). Without it, restic will silently skip those paths.

Grant FDA to two separate things โ€” interactive runs from your terminal and the automated launchd job use different execution paths, and FDA is granted per-binary:

  1. System Settings โ†’ Privacy & Security โ†’ Full Disk Access
  2. Click +, add your terminal app (Terminal, iTerm, etc.) โ€” this covers interactive runs.
  3. Click + again. You’ll need to grant FDA to the binary launchd actually runs under. The simplest option is to add /bin/bash directly (press Cmd+Shift+G in the file picker and enter /bin/bash), since the launch agent’s ProgramArguments starts with /bin/bash. If you prefer, add the restic binary itself (/opt/homebrew/bin/restic on Apple Silicon, /usr/local/bin/restic on Intel) โ€” both approaches work.
  4. You may need to restart Terminal for the interactive grant to take effect.

If you skip this step, the launchd job will run without errors but the resulting snapshots will be missing everything under the FDA-protected directories โ€” and you won’t notice until you need to restore your Mail or Messages.

3.8 Verify It’s Working

source ~/.config/restic/env.sh
restic snapshots

Linux

4.1 Install Restic

Debian/Ubuntu:

sudo apt update && sudo apt install restic

Fedora:

sudo dnf install restic

Arch:

sudo pacman -S restic

Manual download (any distro):

# Download latest release (check https://github.com/restic/restic/releases for current version)
curl -LO https://github.com/restic/restic/releases/download/v0.17.3/restic_0.17.3_linux_amd64.bz2

# Extract and install
bunzip2 restic_*.bz2
chmod +x restic_*
sudo mv restic_* /usr/local/bin/restic

4.2 Create Configuration

mkdir -p ~/.config/restic

Create ~/.config/restic/env.sh:

export RESTIC_REPOSITORY="s3:s3.us-west-004.backblazeb2.com/YOUR-BUCKET-NAME"
export RESTIC_PASSWORD="YOUR_SECURE_PASSWORD_HERE"
export AWS_ACCESS_KEY_ID="YOUR_B2_KEY_ID"
export AWS_SECRET_ACCESS_KEY="YOUR_B2_APPLICATION_KEY"

Secure the file:

chmod 600 ~/.config/restic/env.sh

Replace the placeholder values with your actual credentials.

4.3 Create Exclude File

Create ~/.config/restic/excludes.txt:

# System
.cache
.local/share/Trash
.thumbnails
*.tmp
*.temp
*~

# Browser caches (bookmarks/passwords ARE backed up)
.config/google-chrome/*/Cache
.config/google-chrome/*/Code Cache
.config/google-chrome/*/GPUCache
.config/BraveSoftware/Brave-Browser/*/Cache
.config/BraveSoftware/Brave-Browser/*/Code Cache
.mozilla/firefox/*/cache2
.mozilla/firefox/*/storage

# App caches
.config/Slack/Cache
.config/discord/Cache
.config/spotify/Data
.var/app/*/cache

# Development
node_modules
.venv
__pycache__
*.pyc
.npm
.cargo/registry
# Rust build output. This is scoped to `rust/target` because a bare `target`
# entry would match *any* directory named "target" anywhere in your home โ€”
# including e.g. Documents/target_audiences. Adjust the prefix to wherever
# you actually keep Rust code, or list specific project paths.
rust/target

# Large/replaceable
Downloads/*.iso
Downloads/*.AppImage
*.log

4.4 Initialise Repository

source ~/.config/restic/env.sh
restic init

Save the repository ID shown.

4.5 First Backup

Recommended: Exclude large files

Add --exclude-larger-than 300M to skip files over 300MB. Large files (videos, ISOs, game assets) are often replaceable and expensive to store in the cloud.

restic backup ~ --exclude-file="$HOME/.config/restic/excludes.txt" --exclude-larger-than 300M --verbose

Know what this skips. A 300MB threshold silently excludes most 4K video clips, many stitched panoramas, large RAW panos, and photo library packages. If any of those are irreplaceable to you, raise the threshold or back them up separately โ€” the backup will otherwise finish cleanly without them and you won’t notice they’re missing until you need them.

Optional: Test with a dry run first

source ~/.config/restic/env.sh
restic backup ~ --exclude-file="$HOME/.config/restic/excludes.txt" --exclude-larger-than 300M --dry-run -vv

This shows what would be backed up without uploading anything.

Run the actual backup:

source ~/.config/restic/env.sh
restic backup ~ --exclude-file="$HOME/.config/restic/excludes.txt" --exclude-larger-than 300M --verbose

4.6 Automate with systemd

Create the backup script ~/.config/restic/backup.sh:

#!/bin/bash
set -euo pipefail

# shellcheck source=/dev/null
source ~/.config/restic/env.sh

# Run backup (skip files >300MB). Don't exit on non-zero here โ€” we want to
# inspect the code and only skip forget on a hard failure.
set +e
restic backup ~ --exclude-file="$HOME/.config/restic/excludes.txt" --exclude-larger-than 300M --quiet
rc=$?
set -e

# Only forget old snapshots if backup succeeded.
# Exit 0 = clean success, exit 3 = partial (some files unreadable, snapshot still created).
# Any other code means no usable snapshot โ€” don't touch retention.
if [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ]; then
    restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6
else
    echo "Backup failed with exit code $rc โ€” skipping forget." >&2
    exit "$rc"
fi

Make it executable:

chmod +x ~/.config/restic/backup.sh

Note: this script runs forget (cheap metadata-only) but not prune. Pruning rewrites pack files and downloads data from B2 to do so โ€” running it daily is wasteful and can add meaningful egress cost on larger repos. Schedule pruning separately (see below).

Create the systemd service ~/.config/systemd/user/restic-backup.service:

[Unit]
Description=Restic backup to Backblaze B2

[Service]
Type=oneshot
ExecStart=%h/.config/restic/backup.sh

Create the timer ~/.config/systemd/user/restic-backup.timer:

[Unit]
Description=Daily restic backup

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1800

[Install]
WantedBy=timers.target

Enable and start the timer:

mkdir -p ~/.config/systemd/user
# (copy the service and timer files above)
systemctl --user daemon-reload
systemctl --user enable --now restic-backup.timer

Notes:

  • Persistent=true runs a missed backup when the computer next wakes (laptop-friendly)
  • RandomizedDelaySec=1800 staggers the backup within a 30-minute window (avoids thundering herd if you have multiple machines)
  • %h expands to your home directory in systemd unit files

Enable linger so the timer fires without an active login session:

sudo loginctl enable-linger "$USER"

By default, systemd --user services only run while you have an active graphical or SSH session. If you reboot and leave the machine at the login screen, or run this on a headless box you rarely log into interactively, the timer will silently not fire until someone logs in. enable-linger tells systemd to start your user manager at boot regardless, so scheduled backups actually happen. Run this once per machine.

Create a separate weekly prune job. Add the prune script ~/.config/restic/prune.sh:

#!/bin/bash
set -euo pipefail

# shellcheck source=/dev/null
source ~/.config/restic/env.sh

set +e
restic prune
rc=$?
set -e

if [ "$rc" -ne 0 ]; then
    echo "Prune failed with exit code $rc." >&2
    exit "$rc"
fi
chmod +x ~/.config/restic/prune.sh

Create ~/.config/systemd/user/restic-prune.service:

[Unit]
Description=Restic prune (reclaim space in B2 repository)

[Service]
Type=oneshot
ExecStart=%h/.config/restic/prune.sh

And ~/.config/systemd/user/restic-prune.timer:

[Unit]
Description=Weekly restic prune

[Timer]
OnCalendar=Sun 04:00
Persistent=true
RandomizedDelaySec=1800

[Install]
WantedBy=timers.target

Enable:

systemctl --user daemon-reload
systemctl --user enable --now restic-prune.timer

4.7 Verify It’s Working

# Check timer status
systemctl --user list-timers restic-backup.timer

# Check last run
journalctl --user -u restic-backup.service -n 20

# View snapshots
source ~/.config/restic/env.sh
restic snapshots

Restoring Files

List Your Backups

# macOS/Linux
source ~/.config/restic/env.sh
restic snapshots

# Windows PowerShell
. "$env:USERPROFILE\.config\restic\env.ps1"
restic snapshots

Restore a Specific File

# Find which snapshots contain your file
restic find "important-document.docx"

# Restore from latest backup
restic restore latest --target ~/restore-test --include "Documents/important-document.docx"

Restore Everything (Disaster Recovery)

On a fresh machine:

  1. Install restic
  2. Create env file with your credentials
  3. Restore to a staging directory, not to /:
source ~/.config/restic/env.sh
mkdir -p ~/restore
restic restore latest --target ~/restore

Then inspect ~/restore/ and copy the files you want into their real locations. Restoring directly to / on a live system can clobber configuration, interact badly with running processes, and silently overwrite things you’d rather keep โ€” always stage the restore first.

Alternative: browse the repo as a filesystem. If you want to poke around before deciding what to pull back, restic mount ~/restore-mount exposes every snapshot as a read-only directory tree. Copy out what you need, then umount ~/restore-mount (or Ctrl-C).


Costs

Data SizeMonthly Cost
50 GB~$0.30
100 GB~$0.60
500 GB~$3.00
1 TB~$6.00
  • Upload: Free
  • Download: First 3ร— your storage is free, then $0.01/GB
  • API calls: Essentially free for personal use

Maintenance

Check Backup Health

Quick check (run monthly):

source ~/.config/restic/env.sh  # or PowerShell equivalent
restic check

This validates the repository structure (snapshots, metadata, pack files).

Thorough check (run quarterly):

restic check --read-data

This downloads and verifies all backed-up data. Takes longer and uses bandwidth, but confirms your data is actually intact.

Partial check (compromise):

restic check --read-data-subset=10%

Randomly verifies 10% of your data โ€” useful for large repositories.

View Backup Statistics

restic stats
restic stats latest

Manual Backup

Run anytime you want an immediate backup:

source ~/.config/restic/env.sh
restic backup ~ --exclude-file="$HOME/.config/restic/excludes.txt" --exclude-larger-than 300M

Manual Prune

The automated setup above runs prune weekly. Run it on demand if you’ve just deleted a large amount of data and want to reclaim the space in B2 immediately, rather than waiting for the next scheduled run:

source ~/.config/restic/env.sh

# Preview which snapshots would be forgotten (recommended first)
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --dry-run

# Actually reclaim space
restic prune

# Verify repository integrity after pruning
restic check

Optional: Automatic Git Commits for Your Notes

If you use Obsidian, Logseq, or any markdown-based notes system, git provides fine-grained version history that restic’s daily snapshots can’t match. Accidentally delete a paragraph? Git has it from 10 minutes ago.

Why Git + Restic?

They solve different problems:

  • Git: Tracks every change to text files, lets you see exactly what changed and when
  • Restic: Backs up everything (including large files git ignores) to the cloud

Use both.

Windows: Git Setup

Install git:

winget install --id Git.Git -e --source winget

Initialise your vault:

cd "$env:USERPROFILE\Documents\Obsidian Vault"  # Your vault path
git init
git config user.email "autobackup@local"
git config user.name "Auto-Backup"

Create .gitignore (track only markdown):

# Ignore everything
*

# But track markdown
!*.md

# Track Obsidian config
!.obsidian/
!.obsidian/**

# Track folder structure
!*/

# Track this file
!.gitignore

# Ignore workspace (local state)
.obsidian/workspace*.json

# Ignore trash
.trash/

Create commit script %USERPROFILE%\.config\restic\git-commit.ps1:

$vaultPath = "$env:USERPROFILE\Documents\Obsidian Vault"  # Your vault path
$logFile   = "$env:USERPROFILE\.config\restic\git-commit.log"

# Guard: bail out if vault path doesn't exist
if (-not (Test-Path $vaultPath)) {
    "$(Get-Date -Format 'yyyy-MM-dd HH:mm') ERROR: Vault path not found: $vaultPath" |
        Out-File -Append $logFile
    exit 1
}

Set-Location $vaultPath

# Auto-repair corrupted git objects (e.g. from crash mid-write)
$emptyObjects = Get-ChildItem -Path .git\objects -Recurse -File | Where-Object { $_.Length -eq 0 }
if ($emptyObjects) {
    "$(Get-Date -Format 'yyyy-MM-dd HH:mm') Found empty git objects, repairing..." |
        Out-File -Append $logFile
    $emptyObjects | Remove-Item -Force

    $fsck = git fsck --no-dangling --quiet 2>&1
    if ($LASTEXITCODE -ne 0) {
        $branch = git symbolic-ref --short HEAD 2>$null
        if (-not $branch) { $branch = "main" }
        $reflog = ".git\logs\refs\heads\$branch"

        if (Test-Path $reflog) {
            $lastGood = $null
            foreach ($line in Get-Content $reflog) {
                $old = ($line -split ' ')[0]
                $check = git cat-file -t $old 2>$null
                if ($LASTEXITCODE -eq 0) { $lastGood = $old }
            }
            if ($lastGood) {
                git update-ref "refs/heads/$branch" $lastGood
                "$(Get-Date -Format 'yyyy-MM-dd HH:mm') Reset $branch to $lastGood" |
                    Out-File -Append $logFile
            } else {
                "$(Get-Date -Format 'yyyy-MM-dd HH:mm') ERROR: No valid commit to recover to" |
                    Out-File -Append $logFile
                exit 1
            }
        } else {
            "$(Get-Date -Format 'yyyy-MM-dd HH:mm') ERROR: No reflog for $branch" |
                Out-File -Append $logFile
            exit 1
        }
    }
}

# Check if there are changes
$status = git status --porcelain
if (-not $status) {
    exit 0
}

# Commit all changes
git add -A
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm"
git commit -m "Auto-backup: $timestamp"

"$timestamp Committed" | Out-File -Append $logFile

Schedule every 10 minutes: Create a Task Scheduler task using the same process as the backup task but with:

  • Program: powershell.exe
  • Arguments: -ExecutionPolicy Bypass -File "%USERPROFILE%\.config\restic\git-commit.ps1"
  • Trigger: Daily, repeat every 10 minutes

macOS: Git Setup

Install git (usually pre-installed, or):

xcode-select --install

Initialise your vault:

cd ~/Documents/Obsidian\ Vault  # Your vault path
git init
git config user.email "autobackup@local"
git config user.name "Auto-Backup"

Create .gitignore (same content as Windows above).

Create commit script ~/.config/restic/git-commit.sh:

#!/bin/bash
set -u

# launchd's default PATH doesn't include Homebrew. If you installed git via
# `brew install git` on Apple Silicon, this export is required for the
# scheduled run to find it. (Xcode git at /usr/bin/git is in PATH by default.)
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

VAULT_PATH="$HOME/Documents/Obsidian Vault"  # Your vault path
LOG_FILE="$HOME/.config/restic/git-commit.log"

cd "$VAULT_PATH" || { echo "$(date '+%Y-%m-%d %H:%M') ERROR: Vault path not found: $VAULT_PATH" >> "$LOG_FILE"; exit 1; }

# Auto-repair corrupted git objects (e.g. from crash mid-write)
empty_objects=$(find .git/objects/ -type f -empty 2>/dev/null)
if [[ -n "$empty_objects" ]]; then
    echo "$(date '+%Y-%m-%d %H:%M') Found empty git objects, repairing..." >> "$LOG_FILE"
    while IFS= read -r f; do rm -f "$f"; done <<< "$empty_objects"

    if ! git fsck --no-dangling --quiet >/dev/null 2>&1; then
        branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "main")
        reflog=".git/logs/refs/heads/$branch"
        if [[ -f "$reflog" ]]; then
            last_good=""
            while IFS=' ' read -r old new _rest; do
                if git cat-file -t "$old" >/dev/null 2>&1; then
                    last_good="$old"
                fi
            done < "$reflog"
            if [[ -n "$last_good" ]]; then
                git update-ref "refs/heads/$branch" "$last_good"
                echo "$(date '+%Y-%m-%d %H:%M') Reset $branch to $last_good" >> "$LOG_FILE"
            else
                echo "$(date '+%Y-%m-%d %H:%M') ERROR: No valid commit to recover to" >> "$LOG_FILE"
                exit 1
            fi
        else
            echo "$(date '+%Y-%m-%d %H:%M') ERROR: No reflog for $branch" >> "$LOG_FILE"
            exit 1
        fi
    fi
fi

# Check if there are changes
if git diff --quiet && git diff --cached --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then
    exit 0
fi

# Commit all changes
git add -A
TIMESTAMP=$(date "+%Y-%m-%d %H:%M")
git commit -m "Auto-backup: $TIMESTAMP"

echo "$TIMESTAMP Committed" >> "$LOG_FILE"

Make executable:

chmod +x ~/.config/restic/git-commit.sh

Schedule every 10 minutes: Create ~/Library/LaunchAgents/com.git.autocommit.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.git.autocommit</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>-c</string>
        <string>$HOME/.config/restic/git-commit.sh</string>
    </array>
    <key>StartInterval</key>
    <integer>600</integer>
</dict>
</plist>

Load it (launchctl load is deprecated; use bootstrap):

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.git.autocommit.plist

Linux: Git Setup

Install git (usually pre-installed, or):

# Debian/Ubuntu
sudo apt install git

# Fedora
sudo dnf install git

Initialise your vault:

cd ~/Documents/Obsidian\ Vault  # Your vault path
git init
git config user.email "autobackup@local"
git config user.name "Auto-Backup"

Create .gitignore (same content as Windows/macOS above).

Create commit script ~/.config/restic/git-commit.sh:

#!/bin/bash
set -u

VAULT_PATH="$HOME/Documents/Obsidian Vault"  # Your vault path
LOG_FILE="$HOME/.config/restic/git-commit.log"

cd "$VAULT_PATH" || { echo "$(date '+%Y-%m-%d %H:%M') ERROR: Vault path not found: $VAULT_PATH" >> "$LOG_FILE"; exit 1; }

# Auto-repair corrupted git objects (e.g. from crash mid-write)
empty_objects=$(find .git/objects/ -type f -empty 2>/dev/null)
if [[ -n "$empty_objects" ]]; then
    echo "$(date '+%Y-%m-%d %H:%M') Found empty git objects, repairing..." >> "$LOG_FILE"
    while IFS= read -r f; do rm -f "$f"; done <<< "$empty_objects"

    if ! git fsck --no-dangling --quiet >/dev/null 2>&1; then
        branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "main")
        reflog=".git/logs/refs/heads/$branch"
        if [[ -f "$reflog" ]]; then
            last_good=""
            while IFS=' ' read -r old new _rest; do
                if git cat-file -t "$old" >/dev/null 2>&1; then
                    last_good="$old"
                fi
            done < "$reflog"
            if [[ -n "$last_good" ]]; then
                git update-ref "refs/heads/$branch" "$last_good"
                echo "$(date '+%Y-%m-%d %H:%M') Reset $branch to $last_good" >> "$LOG_FILE"
            else
                echo "$(date '+%Y-%m-%d %H:%M') ERROR: No valid commit to recover to" >> "$LOG_FILE"
                exit 1
            fi
        else
            echo "$(date '+%Y-%m-%d %H:%M') ERROR: No reflog for $branch" >> "$LOG_FILE"
            exit 1
        fi
    fi
fi

# Check if there are changes
if git diff --quiet && git diff --cached --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then
    exit 0
fi

# Commit all changes
git add -A
TIMESTAMP=$(date "+%Y-%m-%d %H:%M")
git commit -m "Auto-backup: $TIMESTAMP"

echo "$TIMESTAMP Committed" >> "$LOG_FILE"

Make executable:

chmod +x ~/.config/restic/git-commit.sh

Schedule every 10 minutes with systemd:

Create ~/.config/systemd/user/git-autocommit.service:

[Unit]
Description=Auto-commit git changes

[Service]
Type=oneshot
ExecStart=%h/.config/restic/git-commit.sh

Create ~/.config/systemd/user/git-autocommit.timer:

[Unit]
Description=Auto-commit git changes every 10 minutes

[Timer]
OnBootSec=5min
OnUnitActiveSec=10min

[Install]
WantedBy=timers.target

Enable:

systemctl --user daemon-reload
systemctl --user enable --now git-autocommit.timer

Recovering from Git

# See recent commits
git log --oneline -20

# See what changed in a commit
git show abc1234

# Restore a deleted file
git checkout HEAD~1 -- "path/to/file.md"

# See file at a specific time
git show HEAD~5:"path/to/file.md"

Troubleshooting

Understanding Exit Codes

Restic uses exit codes to indicate what happened:

  • 0: Success โ€” backup completed normally
  • 1: Fatal error โ€” no snapshot created
  • 3: Partial success โ€” some files couldn’t be read, but snapshot was created

Exit code 3 is common if some files are locked by other applications. The backup still succeeds for everything else.

“repository does not exist”

Check your RESTIC_REPOSITORY path. The format is:

s3:s3.REGION.backblazeb2.com/BUCKET-NAME

“invalid credentials”

Verify AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY match your B2 application key exactly.

Backup is slow

First backup uploads everything โ€” this is normal. Subsequent backups only upload changes and should be much faster.

To limit bandwidth during work hours, add --limit-upload 5000 (5 MB/s) to your backup command.

Task Scheduler / launchd not running

Windows: Check Task Scheduler โ†’ Task History. Common issues:

  • PowerShell execution policy blocking the script
  • Wrong path to script

macOS: Check /tmp/restic-backup.log (and /tmp/restic-prune.log) for errors. Common issues:

  • restic: command not found โ€” launchd’s PATH doesn’t include Homebrew. The export PATH=... line in backup.sh should fix this; verify it’s present.
  • Missing files in snapshots โ€” Full Disk Access not granted to the binary launchd runs under. See ยง3.7.
  • operation not permitted on ~/Library/Mail or similar โ€” same FDA issue.

Key Points

  • Save your password. If you lose it, your backups are unrecoverable. Put it in your password manager.
  • Test restores. Periodically restore a file to verify your backups work.
  • First backup takes time. Let it run overnight if needed.
  • This complements sync services. Use OneDrive/iCloud for convenience, restic for disaster recovery.