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?
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.--deleteremoves 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-destis 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. latestis 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 - KEEPentries. Thefindname 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-runto 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
- 0058 Safe bash script template with set ‑euo pipefail and trap A starting point for bash scripts that stops on errors, rejects unset variables, cleans up temp files on exit and parses options.… Intermediate · Reference · Linux, macOS Script Shell scripting 15 Oct 2026
- 0061 Fix: Address already in use (free a busy port) Your server cannot start because another process already listens on its port. This script shows which process holds the port and stops… Intermediate · Fix · Linux, macOS Script Processes & ports · Shell scripting 15 Oct 2026
- 0108 List all Proxmox VE guests with status and locks (pvesh + jq) A Bash script that lists every VM and container in the cluster with node, status, lock, memory and uptime, using the Proxmox… Intermediate · Reference · Linux Script Shell scripting · VMs & containers 15 Oct 2026
- 0109 A vzdump wrapper script with checks, retention and a log file A Bash wrapper around vzdump that checks the target storage first, backs up chosen guests or all guests with zstd and keep-last… Intermediate · How-to · Linux Script Backup & restore · Shell scripting 15 Oct 2026
- 0265 Turn a one-liner into a robust bash script: strict mode, options, logging, traps, locking and a schedule Grow a find-and-gzip one-liner step by step into a dependable bash script with strict mode, getopts, usage text, logging, error traps, dry-run,… Intermediate · Tutorial · Linux, macOS Article Services & systemd · Shell scripting 15 Oct 2026
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- Commitpedia +4,180 chars