Back Up Your Home Directory with rsync

What this does

Copies your home directory to a backup destination using rsync, keeps the last N dated snapshots, and uses --link-dest so unchanged files share inodes with the previous snapshot — meaning ten snapshots of a 50 GB home take much less than 500 GB. Skips obvious junk (caches, build artefacts) and refuses to run if the destination is missing.

When this is useful

This is the "I want a real backup of my laptop on an external drive" script. It is not a replacement for an off-site, encrypted, versioned solution (Borg, restic, Duplicity), but it is a much better answer than copy-pasting a single rsync command from memory and hoping you remembered --delete correctly.

The snapshot-with-hardlinks pattern is the same trick that Apple's Time Machine uses underneath. Each backup directory looks complete on its own, even though most of its files are shared with the previous backup.

Read this first

rsync --delete is the option that makes a backup a true mirror — files deleted from the source are also removed from the latest snapshot. Older snapshots still contain them, but if you misconfigure the source and destination, the script will happily mirror the result. Run with --dry-run the first time. Always.

The script

backup-home.sh
#!/usr/bin/env bash
#
# backup-home.sh — snapshot-style rsync backup of $HOME.
#
# Usage:
#   ./backup-home.sh [--dry-run]
#
# Configuration: edit the values below or override with environment vars.
#
set -euo pipefail

# --- Configuration ------------------------------------------------------

SRC="${SRC:-$HOME/}"                       # trailing slash matters
DEST_ROOT="${DEST_ROOT:-/mnt/backup/$(hostname)}"
KEEP="${KEEP:-14}"                         # snapshots to keep
EXCLUDE_FILE="${EXCLUDE_FILE:-$HOME/.config/backup-home.exclude}"

# --- Argument parsing ---------------------------------------------------

DRY_RUN=()
for arg in "$@"; do
  case "$arg" in
    --dry-run) DRY_RUN=(--dry-run) ;;
    -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
    *) echo "Unknown argument: $arg" >&2; exit 2 ;;
  esac
done

# --- Sanity checks ------------------------------------------------------

if [ ! -d "$SRC" ]; then
  echo "Source $SRC does not exist." >&2; exit 1
fi
if [ ! -d "$DEST_ROOT" ]; then
  echo "Destination $DEST_ROOT does not exist." >&2
  echo "Mount the backup volume or create the directory first." >&2
  exit 1
fi

# --- Default excludes ---------------------------------------------------

if [ ! -f "$EXCLUDE_FILE" ]; then
  mkdir -p "$(dirname "$EXCLUDE_FILE")"
  cat >"$EXCLUDE_FILE" <<'EOF'
# Caches, build artefacts, and other junk you don't want in a backup.
.cache/
.thumbnails/
.local/share/Trash/
.mozilla/firefox/*/Cache*/
.mozilla/firefox/*/cache2/
.config/google-chrome/*/Cache/
.config/chromium/*/Cache/
.npm/
.cargo/registry/
.gradle/caches/
node_modules/
target/
build/
dist/
__pycache__/
.venv/
.tox/
*.tmp
*.swp
*~
EOF
  echo "Created default exclude list at $EXCLUDE_FILE"
fi

# --- Snapshot naming ----------------------------------------------------

STAMP=$(date +%Y-%m-%dT%H-%M-%S)
TARGET="$DEST_ROOT/$STAMP"
LATEST="$DEST_ROOT/latest"

# --- Find a previous snapshot for --link-dest ---------------------------

LINK_DEST_ARG=()
if [ -L "$LATEST" ] && [ -d "$LATEST" ]; then
  LINK_DEST_ARG=(--link-dest="$(readlink -f "$LATEST")")
fi

# --- Run rsync ----------------------------------------------------------

rsync \
  -aHAX \
  --info=progress2 \
  --human-readable \
  --delete \
  --delete-excluded \
  --exclude-from="$EXCLUDE_FILE" \
  "${LINK_DEST_ARG[@]}" \
  "${DRY_RUN[@]}" \
  "$SRC" "$TARGET/"

# --- Update the 'latest' symlink ---------------------------------------

if [ "${#DRY_RUN[@]}" -eq 0 ]; then
  ln -sfn "$TARGET" "$LATEST"
fi

# --- Rotate old snapshots ----------------------------------------------

mapfile -t SNAPS < <(ls -1 "$DEST_ROOT" | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}T' | sort -r)

if [ "${#SNAPS[@]}" -gt "$KEEP" ]; then
  for old in "${SNAPS[@]:$KEEP}"; do
    if [ "${#DRY_RUN[@]}" -gt 0 ]; then
      echo "Would remove: $DEST_ROOT/$old"
    else
      echo "Removing old snapshot: $old"
      rm -rf -- "$DEST_ROOT/$old"
    fi
  done
fi

echo "Done."

How to use it

First run
# Save as backup-home.sh and make it executable
chmod +x backup-home.sh

# Mount your backup drive (example):
sudo mkdir -p /mnt/backup
sudo mount /dev/sdb1 /mnt/backup
sudo mkdir -p "/mnt/backup/$(hostname)"
sudo chown "$USER:$USER" "/mnt/backup/$(hostname)"

# Dry run first
./backup-home.sh --dry-run

# Then for real
./backup-home.sh

After a few runs you will see one dated directory per backup, plus a latest symlink pointing at the most recent one:

$ ls /mnt/backup/$(hostname)/
2026-05-01T03-00-12
2026-05-02T03-00-08
2026-05-03T03-00-09
latest -> 2026-05-03T03-00-09

Each snapshot is browsable like a normal directory. Restoring a file is just cp from the snapshot back to your home.

How --link-dest saves space

The flag tells rsync: "look at this previous backup; for any file that hasn't changed, hard-link to it instead of copying." A hard link is a second name for the same inode, so the second copy takes no extra space. The next day's backup looks like a complete copy, but the unchanged files are shared with yesterday's.

The practical effect: ten snapshots of a 50 GB home directory often fit in 60–70 GB on disk, because most files don't change from day to day. Only the files you actually edited get a new on-disk copy.

If you delete a snapshot, only the files unique to that snapshot are freed; everything still referenced by another snapshot stays.

Scheduling it

Either cron or a systemd timer works. The systemd approach is described in more detail on the systemd basics page; the short version:

# ~/.config/systemd/user/backup-home.service
[Unit]
Description=Snapshot backup of $HOME with rsync

[Service]
Type=oneshot
ExecStart=%h/bin/backup-home.sh

# ~/.config/systemd/user/backup-home.timer
[Unit]
Description=Run backup-home daily

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

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now backup-home.timer

Use systemctl --user list-timers to confirm it's scheduled, and journalctl --user -u backup-home.service to read the output of the last run.

Common mistakes

  • Pointing the destination at the same filesystem as the source. Defeats the point of a backup. The script doesn't enforce this; that's your responsibility.
  • Forgetting that --link-dest only works on the same filesystem. Hard links can't span filesystems. If you move snapshots around, the space savings collapse.
  • Trailing slashes on SRC. $HOME/ means "the contents of HOME". $HOME (no slash) means "the HOME directory itself, including its name". Pick one and be consistent.
  • Backing up encrypted home directories. If your home is encrypted (eCryptfs, fscrypt), the backup is in plaintext on the destination. That's almost always fine, but it's worth knowing.
  • Running this as root by mistake. The script is intended to run as your user. Root's umask and ownership will produce a backup you can't read back without sudo.

Related reading