How it works in one sentence

rsync --link-dest=<previous-snapshot> <source> <new-snapshot> compares each source file against the same path in <previous-snapshot>; if identical (same content, size, mtime), rsync creates a hard link instead of copying. New / changed files copy fully. Result: <new-snapshot> looks like a complete tree, but disk usage is only the changed files.

The minimum-viable script

#!/bin/bash
# /usr/local/bin/snapshot-backup.sh
set -euo pipefail

SOURCE=/home/amir
DEST=/mnt/backup/snapshots
TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)

# Most-recent existing snapshot becomes our --link-dest reference
LAST=$(ls -1tr "$DEST" 2>/dev/null | tail -n 1 || true)
LAST_OPT=""
if [ -n "$LAST" ]; then
    LAST_OPT="--link-dest=$DEST/$LAST"
fi

# Make the new snapshot
NEW="$DEST/$TIMESTAMP"
mkdir -p "$NEW"

rsync -aHAX --numeric-ids --delete \
    $LAST_OPT \
    --exclude='.cache' --exclude='.local/share/Trash' --exclude='node_modules' \
    "$SOURCE/" "$NEW/"

# Atomic "current" symlink
ln -sfn "$TIMESTAMP" "$DEST/current"

# Prune older than 30 days (keep all daily for 14d, then weekly for 30d, then monthly)
# Simplest version: keep last 30 snapshots, delete older
ls -1tr "$DEST" | grep -v '^current$' | head -n -30 | while read OLD; do
    rm -rf "$DEST/$OLD"
done

Run from a systemd timer (see that tutorial) nightly. Each run takes the time to scan + copy only the changed files; subsequent days are typically minutes even for large home directories.

What the flags mean

  • -a — archive mode (recurses, preserves owner/group/permissions/timestamps/symlinks/special-files)
  • -H — preserve hard links in the source itself
  • -A — preserve ACLs
  • -X — preserve extended attributes (important on Linux for SELinux, capabilities, file annotations)
  • --numeric-ids — preserve UIDs/GIDs as numbers (don't try to map across systems; UID 1000 stays UID 1000)
  • --delete — remove files in $NEW that are no longer in $SOURCE
  • --link-dest=<path> — hard-link unchanged files from this path instead of copying

What the snapshots look like on disk

$ ls /mnt/backup/snapshots/
2026-05-20_03:00:00
2026-05-21_03:00:00
2026-05-22_03:00:00
current -> 2026-05-22_03:00:00

$ du -sh /mnt/backup/snapshots/*
12G   2026-05-20_03:00:00         # apparent size
12G   2026-05-21_03:00:00         # apparent size (but mostly hard links)
12G   2026-05-22_03:00:00         # apparent size (but mostly hard links)

# Actual disk usage (de-duplicated by hard link)
$ du -sh /mnt/backup/snapshots/
12.3G  /mnt/backup/snapshots/

Each snapshot directory looks like a 12 GB tree; the total disk usage across all snapshots is one full copy plus the daily deltas.

Restoring

The killer property: nothing special required. Each snapshot is a real directory tree.

# Restore one file
cp /mnt/backup/snapshots/2026-05-15_03:00:00/Documents/important.docx ~/Documents/

# Restore a directory
rsync -av /mnt/backup/snapshots/2026-05-15_03:00:00/Documents/ ~/Documents/

# Browse a snapshot
ls /mnt/backup/snapshots/2026-05-15_03:00:00/

# Compare yesterday vs today
diff -rq /mnt/backup/snapshots/{2026-05-21_03:00:00,2026-05-22_03:00:00}

No "restore wizard," no separate restore tool, no backup-tool-specific knowledge required. Years from now, when whatever backup software you used is gone, the snapshots are still readable by any Unix.

Across machines: rsync over SSH

# Pull-style: backup server pulls from each client
rsync -aHAX --numeric-ids --delete \
    --link-dest=/mnt/backup/snapshots/amir-laptop/$LAST_LAPTOP \
    amir@laptop:/home/amir/ \
    /mnt/backup/snapshots/amir-laptop/$TIMESTAMP/

# Or push-style: client pushes to backup server
rsync -aHAX --numeric-ids --delete \
    --link-dest=/mnt/backup/snapshots/$LAST \
    /home/amir/ \
    backup-server:/mnt/backup/snapshots/$TIMESTAMP/

Pull-style is generally cleaner: the backup server holds the credentials (one direction of trust); clients don't need any special config beyond an SSH key the server uses.

Retention with proper rotation

The "keep last 30" pattern is simple but loses long-term history. A more useful retention:

# Keep:
#   - all of last 14 days
#   - weekly snapshots for 8 weeks
#   - monthly snapshots for 12 months

# Easiest approach: organize folders by day-of-week and week-of-month
DAILY_DIR="$DEST/daily"
WEEKLY_DIR="$DEST/weekly"
MONTHLY_DIR="$DEST/monthly"

# Daily snapshot
do_snapshot "$DAILY_DIR" 14            # rotate among 14 dated subfolders

# Weekly — on Sunday, copy daily/today to weekly
if [ "$(date +%w)" = "0" ]; then
    cp -al "$DAILY_DIR/$TIMESTAMP" "$WEEKLY_DIR/$(date +%Y-W%U)"
    # cp -al = archive + hard-link; doesn't double disk space
fi

# Monthly — on the 1st of the month
if [ "$(date +%d)" = "01" ]; then
    cp -al "$DAILY_DIR/$TIMESTAMP" "$MONTHLY_DIR/$(date +%Y-%m)"
fi

Three tiers, hard-linked together, total disk usage still O(1 full copy + total deltas across all retention).

Limitations

  • Same filesystem required for hard links. The destination must be one filesystem (you can't --link-dest across mounts). For multi-disk setups, span them with LVM / mdadm (see that tutorial) or ZFS / Btrfs.
  • No encryption. The snapshots are plaintext on disk. For an untrusted backup target, layer LUKS underneath or use restic instead.
  • No content-defined deduplication. Hard links match whole files; if a 1 GB file has 1 byte changed, the new snapshot stores the full 1 GB again. restic / borg dedup at chunk level and would store ~64 KB.
  • Lots of small files = lots of inodes. Each snapshot still has a directory entry for every file. On filesystems with low inode limits, this matters; ext4 / xfs / zfs are fine in practice for normal home-dir use.
  • No remote object store. rsync wants a Unix filesystem. For "back up to S3," use restic.

When --link-dest is the right tool

  • Backups to a local USB drive or NAS, on the same trust domain as the source.
  • You want browseable snapshots without learning a backup tool's CLI.
  • You want backup data that's restorable with standard Unix commands forever.
  • The data isn't sensitive enough to require client-side encryption.

For everything else, restic (see that tutorial) or Btrfs send/receive (see that tutorial) fits better. Often the right answer is both: rsync --link-dest to a local disk for "I deleted a file yesterday, restore it in 10 seconds" plus restic to S3 for "the house burns down, restore from elsewhere."