Skip to content
Commitpedia
Log in Join & edit

Incremental snapshot backups with rsync

A bash script that copies a folder into dated snapshots with rsync, hard-links unchanged files to the previous snapshot to save space, and keeps only the newest N.

Not checked by a trusted editor yet Last edited Outdated?

Edit history of Incremental snapshot backups with rsync: 1 recorded edit
Edit history · 1 edit
Type
Script
Edits
1
Editors
1
First issued
15 Oct 2026
Last edit
15 Oct 2026
Language
bash
Licence
CC0-1.0

What this changes

Creates DEST_DIR and a new dated snapshot folder in it, copies SOURCE_DIR there with rsync, points DEST_DIR/latest at it, and permanently deletes the oldest dated snapshot folders beyond KEEP (default 7). SOURCE_DIR is only read.

How to undo it

Remove a snapshot with rm -rf DEST_DIR/STAMP and repoint latest. Pruned snapshots cannot be recovered, so set KEEP high enough and give the script its own DEST_DIR.

This script makes a full-looking copy of a folder every time it runs, but only stores files that changed. Each snapshot is an ordinary folder you can browse or restore from with cp, and old snapshots are pruned automatically.

The script

Check before you run this

  • Deletes files and folders without asking. Check the path before running.
#!/usr/bin/env bash
set -euo pipefail

# Usage: backup.sh SOURCE_DIR DEST_DIR [KEEP]
SRC=${1:?usage: backup.sh SOURCE_DIR DEST_DIR [KEEP]}
DEST=${2:?usage: backup.sh SOURCE_DIR DEST_DIR [KEEP]}
KEEP=${3:-7}

[[ -d "$SRC" ]] || { echo "source not found: $SRC" >&2; exit 1; }
mkdir -p "$DEST"

STAMP=$(date '+%Y-%m-%d_%H%M%S')
TARGET="$DEST/$STAMP"
LATEST="$DEST/latest"

LINK_ARGS=()
if [[ -d "$LATEST" ]]; then
  LINK_ARGS=(--link-dest="$(cd "$LATEST" && pwd -P)")
fi

rsync -a --delete \
  --exclude='.DS_Store' --exclude='node_modules/' \
  ${LINK_ARGS[@]+"${LINK_ARGS[@]}"} \
  "$SRC"/ "$TARGET"/

ln -sfn "$STAMP" "$LATEST"
echo "snapshot: $TARGET"

# Keep only the newest $KEEP snapshots (names sort by date).
SNAPSHOTS=()
while IFS= read -r dir; do
  SNAPSHOTS+=("$dir")
done < <(find "$DEST" -mindepth 1 -maxdepth 1 -type d -name '20??-??-??_??????' | sort)

COUNT=${#SNAPSHOTS[@]}
if (( COUNT > KEEP )); then
  for old in "${SNAPSHOTS[@]:0:COUNT-KEEP}"; do
    echo "pruning: $old"
    rm -rf -- "$old"
  done
fi

Usage

chmod +x backup.sh
./backup.sh ~/Documents /Volumes/BackupDrive/documents      # keep 7 snapshots
./backup.sh ~/projects /mnt/backup/projects 30               # keep 30

The result looks like this:

/mnt/backup/projects/
  2026-10-13_020000/
  2026-10-14_020000/
  2026-10-15_020000/
  latest -> 2026-10-15_020000

How it works

  • rsync -a (archive mode) copies recursively and preserves permissions, timestamps and symbolic links.
  • --delete removes files from the new snapshot that no longer exist in the source, so each snapshot is an exact mirror of that moment. Older snapshots still have them.
  • --link-dest is the trick: for every file that is unchanged since the previous snapshot, rsync creates a hard link to the existing copy instead of copying it again. Unchanged files take no extra space, yet every snapshot looks complete.
  • The trailing slash in "$SRC"/ means “the contents of the folder”. Without it, rsync would create an extra folder level inside the snapshot.
  • latest is a symbolic link updated after each successful run, so the next run knows what to link against.
  • Snapshot names sort by date, so the oldest ones come first and pruning deletes the first COUNT - KEEP entries. The find name pattern means only snapshot folders are ever pruned.

The odd-looking ${LINK_ARGS[@]+"${LINK_ARGS[@]}"} expands the array only if it has items. It avoids an “unbound variable” error under set -u on older bash versions when the first snapshot has nothing to link to.

Running it on a schedule

On Linux, add a line with crontab -e to run every night at 02:00:

0 2 * * * /home/ana/bin/backup.sh /home/ana/projects /mnt/backup/projects 14 >> /home/ana/backup.log 2>&1

Cron runs with a minimal environment, so use absolute paths everywhere. On macOS, cron still works, but a launchd agent is the native option.

Things to know

  • Try it first with a small test folder, and add --dry-run to the rsync line to see what would happen without copying anything.
  • Hard links only work within one filesystem, so all snapshots must live on the same backup drive. The destination filesystem must support hard links, which rules out FAT32 and exFAT drives.
  • macOS ships its own rsync implementation, which differs from GNU rsync in some options. The flags used here work with both; if you add others, test them on each system.
  • To back up to another machine, put the snapshots on a disk mounted there, or adapt the script to run on the backup server and pull from your machine over SSH.
  • A backup you have never restored is a hope, not a backup. Occasionally copy a file back out of an old snapshot to prove it works.

See also

History

Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.

  1. Commitpedia +4,180 chars

Report a problem

Something wrong, unsafe or copied? Tell the editors.
What is wrong?