Skip to content
Go back

rsync --link-dest: Snapshot Backups

By KingPin 13 min read
rsync --link-dest: Snapshot Backups
Contents

Your Backup Tool Is a Subscription to Someone Else’s Bugs

It’s 2 AM. You deleted the wrong config file three days ago, and you want the version from last Tuesday. Your backup setup is a cron job that runs rsync -a /home /mnt/backup and overwrites the same directory every night. Last Tuesday is gone. So is the file.

You do not need a backup tool with a repository format, a password, and a restore wizard for this. Plain rsync can keep a folder per day, each one looking like a complete copy of your data, while storing unchanged files only once. The trick is hard links, and the flag is --link-dest. Add a systemd timer and you have a rolling snapshot system in about 40 lines of shell.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/linux/rsync-link-dest-snapshots

This is the same idea behind rsnapshot and Time Machine’s older disk-based snapshots. Those tools add scheduling, config files, and a UI. If you can read a 40-line script, you can skip them.

A file on a Linux filesystem is an inode (the data and metadata) plus one or more names pointing at it. A hard link is a second name for the same inode. No copy happens. Both names show the same content, and the data survives until the last name is removed.

Terminal window
echo hello > a.txt
ln a.txt b.txt
stat -c '%h links, inode %i, %n' a.txt b.txt

Both lines report the same inode and a link count of 2. Delete a.txt and b.txt still holds the data. That last part is what makes backups work: if Monday’s snapshot and Tuesday’s snapshot both name the same inode for report.pdf, then deleting Monday’s folder only drops one name. Tuesday’s copy stays intact.

Hard links need a filesystem that supports them. ext4, XFS, Btrfs, and ZFS do. FAT and exFAT do not, so a USB drive formatted for Windows compatibility will not work for this. Format the backup disk as ext4 or XFS and move on with your life.

Run a normal rsync -a src/ dest/new/ and every file gets copied. Add --link-dest=DIR and rsync checks each file against the same path in DIR. If the file matches, rsync creates a hard link to the file in DIR instead of transferring it. If the file differs or is missing, it copies it as usual.

Terminal window
rsync -a /home/you/ /srv/backups/home/2026-11-08/
rsync -a --link-dest=/srv/backups/home/2026-11-08 \
/home/you/ /srv/backups/home/2026-11-09/

The second command still walks the whole source, but it moves almost no data. Both folders look like complete copies. You can ls, diff, or cp out of either one. No restore tool needed.

A relative --link-dest path resolves against the destination directory, not your current directory. I checked: with the destination dest/s2/ and --link-dest=../s1, rsync linked dest/s2/big.bin to dest/s1/big.bin, same inode, link count 2. That ../ is the gotcha that bites first. It is also what makes the latest symlink in the script below work.

The trap: a wrong path fails quietly

I pointed --link-dest at a directory that does not exist. Exit code: 0. rsync did a full copy and called it a success. The only sign was one warning line on stderr:

--link-dest arg does not exist: /tmp/tmp.6ze4CnkAWm/dest/a/../nope

So a typo in the path turns your space-saving snapshots into full copies, and nothing fails. Under a systemd timer that warning lands in the journal, so grep journalctl -u rsync-snapshot.service for link-dest arg does not exist, and check your first few runs with du.

Measuring What You Saved

du counts each inode once per invocation. Give it several snapshot directories in a single command and the shared files get charged to the first directory only:

Terminal window
du -sh /srv/backups/home/2026-11-08 /srv/backups/home/2026-11-09

In my test, the first snapshot held a 1 MB file and the second was fully linked. du -sh on both together printed 984K for the first and 0 for the second. Swap the argument order and the numbers swap too. Run du -sh on each snapshot in separate commands and each one reports its full size, which tells you nothing about the savings.

So read the multi-argument output as “what each snapshot adds on top of the ones listed before it.” For the overall total, du -sh /srv/backups/home on the whole tree gives you the real disk usage.

The Costs Nobody Puts on the Box

Hard-link snapshots are cheap, but they send a bill. Four things cost you.

A changed file is copied whole. There is no block-level dedup across snapshots. I changed one byte in a 1 MB file. The new snapshot got a brand-new inode, link count 1, and a full 1 MB of data. Scale that up: a 1-byte change in a 10 GB VM disk image costs 10 GB per snapshot. Databases, VM images, and big mbox files are bad fits. Dump the database first, or look at something that chunks data, like the tools in restic vs Borg vs Kopia.

A metadata-only change breaks the link. The man page says files must be identical in all preserved attributes to link. With -a, that covers permissions, ownership, and modification time. I ran chmod 600 on a file and the next snapshot got a new inode with link count 1. A touch with a different date did the same. The data was identical. rsync still made a local copy and updated its attributes. A chown -R across a big tree will make your next snapshot a full copy.

Directories are never shared. Hard links work on files. Every snapshot recreates the full directory tree, so each snapshot costs one inode per directory, plus one per changed file. With a million directories you will add a million inodes per snapshot. Check df -i on the backup disk, since ext4 fixes its inode count at format time.

Deleting a snapshot frees only what nothing else links to. I removed the oldest snapshot folder and watched disk usage: unchanged. Every file in it still had another name in a newer snapshot. Data blocks are freed when the link count hits zero. That is the behavior you want. It also means “delete the biggest snapshot to free space” does not work the way you would guess.

The Script

Here is the script. It does five jobs: take a lock, rsync into a .partial directory, rename it into place only on success, repoint latest, and prune.

snapshot.sh
#!/usr/bin/env bash
set -euo pipefail
SRC=${1:?usage: snapshot.sh SRC DEST [KEEP]}
DEST=${2:?usage: snapshot.sh SRC DEST [KEEP]}
KEEP=${3:-14}
[ "$KEEP" -ge 1 ] || { echo "KEEP must be at least 1" >&2; exit 1; }
RSYNC_OPTS=${RSYNC_OPTS:--aH --numeric-ids --delete}
mkdir -p "$DEST"
exec 9>"$DEST/.lock"
flock -n 9 || { echo "another snapshot is running" >&2; exit 1; }
name=$(date +%Y-%m-%d_%H%M%S)
[ -e "$DEST/$name" ] && { echo "snapshot $name already exists" >&2; exit 1; }
partial="$DEST/.partial"
mkdir -p "$partial"
args=()
[ -L "$DEST/latest" ] && args+=(--link-dest=../latest)
rc=0
# shellcheck disable=SC2086
rsync $RSYNC_OPTS "${args[@]}" "${SRC%/}/" "$partial/" || rc=$?
if [ "$rc" -ne 0 ] && [ "$rc" -ne 24 ]; then
echo "rsync failed with exit $rc; latest left untouched" >&2
exit "$rc"
fi
mv "$partial" "$DEST/$name"
ln -s "$name" "$DEST/latest.tmp"
mv -T "$DEST/latest.tmp" "$DEST/latest"
mapfile -t old < <(find "$DEST" -mindepth 1 -maxdepth 1 -type d \
-name '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9]' \
-printf '%f\n' | sort | head -n "-$KEEP")
for d in "${old[@]}"; do
rm -rf -- "${DEST:?}/$d"
done

(The file in the example repo has comments and a summary line; this is the same logic.)

Usage is snapshot.sh /home/you /srv/backups/home 14. A few decisions in there deserve a sentence each.

The .partial dance. If the box loses power halfway through, rsync leaves a half-built directory. If that directory were named like a snapshot and latest pointed at it, tomorrow’s run would link against incomplete data and quietly treat the missing files as new. Writing into .partial and renaming on success means a snapshot name always means a complete snapshot. The test suite swaps in a fake rsync that exits 30 and confirms latest does not move and no dated directory appears. A leftover .partial is reused on the next run, so rsync resumes instead of starting over.

--link-dest=../latest. The destination is .partial inside DEST, so ../latest resolves to DEST/latest, a symlink to the newest snapshot. On the very first run there is no latest, so the flag is skipped and you get a full copy.

Exit code 24. rsync returns 24 when source files vanish mid-run, which is normal when you back up a live system with caches and temp files. The script treats it as success. Every other nonzero code is a failure.

mv -T on the symlink. Creating latest.tmp and renaming it over latest is atomic. Nobody ever sees a missing latest.

Pruning. Names are YYYY-MM-DD_HHMMSS, so they sort chronologically. head -n -14 keeps the newest 14 off the delete list, and rm -rf deletes the rest. Because of the hard links, the files in a deleted snapshot that newer snapshots still use stay put.

The flags, one by one

-a is archive mode: recurse, keep permissions, times, symlinks, group, devices, and owner (owner only when rsync runs as root). -H preserves hard links that exist inside your source, so two source names for one inode stay linked in the snapshot instead of becoming two copies. --numeric-ids transfers raw UID and GID numbers instead of mapping them through user and group names at each end. That matters for restores: a snapshot taken from a container host or a different machine should carry the numbers it had. --delete removes files from .partial that no longer exist in the source. On a fresh directory it does nothing. After an interrupted run it cleans up leftovers.

Add -A to preserve ACLs and -X for extended attributes if you use them. Both require support on the backup filesystem, and an attribute difference breaks links the same way a chmod does, so only add them if you need them.

Pulling from Another Machine

You can run the backup box as the puller:

Terminal window
snapshot.sh backup-user@fileserver:/srv/data /srv/backups/fileserver 30

This works because the comparison directory is evaluated on the receiving side. The man page describes --compare-dest, which --link-dest is built on, as using a directory “on the destination machine.” In a pull, the backup box is the destination, so ../latest is a local path on the backup disk. The source machine never needs to see your snapshots.

Pushing to a remote backup server also works with plain rsync, with the server as the receiver. This script does its rename and prune steps locally, though, so it fits the pull model. That is why I pull.

The Timer

A cron line would work. A systemd timer gives you Persistent=true, which runs a missed job when the machine comes back, and the journal for logs. If you want the comparison, see systemd timers vs cron.

rsync-snapshot.service
[Unit]
Description=rsync hard-link snapshot of /home/you
RequiresMountsFor=/srv/backups
[Service]
Type=oneshot
ExecStart=/usr/local/bin/snapshot.sh /home/you /srv/backups/home 14
Nice=10
IOSchedulingClass=idle
rsync-snapshot.timer
[Unit]
Description=Daily rsync snapshot
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.target

Install it:

Terminal window
sudo cp snapshot.sh /usr/local/bin/
sudo cp rsync-snapshot.service rsync-snapshot.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now rsync-snapshot.timer
systemctl list-timers rsync-snapshot.timer

RequiresMountsFor=/srv/backups makes systemd mount the backup disk before the job starts and fails the job if that mount fails. Without it, a missing mount means your snapshots land on the root filesystem and fill it up. This needs an /etc/fstab entry for the disk; with no mount unit to depend on, systemd has nothing to wait for and runs the job anyway. Nice=10 and IOSchedulingClass=idle keep the nightly walk of your files from competing with whatever else the box does at 3 AM.

Run it once by hand with sudo systemctl start rsync-snapshot.service, then read journalctl -u rsync-snapshot.service. Do this before you trust it.

Restoring

A restore is a copy. Browse /srv/backups/home/2026-11-05_030012/, find the file, cp -a it back. To bring back a whole tree, run rsync the other direction:

Terminal window
rsync -a /srv/backups/home/latest/ /home/you/

Do a restore drill once, on a spare directory. A backup you have never restored is a hope, not a backup. Our backup restore testing post covers the habit in more detail.

The Same Trick in Tools You Already Know

rsnapshot is this pattern with a config file and a rotation scheme (daily.0, daily.1, and so on). It drives rsync and hard links to share unchanged files between snapshots. Time Machine on older HFS+ disks used hard links for the same purpose (Apple’s version linked whole directories, which Linux filesystems forbid). This is a well-worn pattern, and your version is the readable one.

What you give up compared to dedicated tools: encryption, compression, block-level dedup, and integrity checks. Snapshots are plain files, so anyone with the disk can read them. For offsite or untrusted storage, use a tool that encrypts. For a local backup disk or a NAS in the closet, plain files you can ls at 2 AM are a feature.

The SumGuy Take

Hard-link snapshots are the forklift for the moving-a-couch job: simple, obvious, and boring in the way backups should be. They handle documents, photos, configs, and source trees without breaking a sweat. They handle VM images and databases poorly, because a changed file costs its full size every time.

Keep this as one layer of a bigger plan. A second disk in the same room is still the same fire. Send an encrypted copy somewhere else, and read the 3-2-1-1-0 strategy for how many copies you actually want. Then run the test script, break something on purpose, and restore it before the real 2 AM arrives.

Common Questions

No. exFAT and FAT have no hard links, so rsync copies every file in full and each snapshot takes the full size of the source. Use ext4, XFS, Btrfs, or ZFS for the backup destination. To test any other filesystem, run ln a b on it: if that fails, --link-dest saves nothing.

One full copy plus the size of every file that changed since the previous snapshot. A changed file is stored whole, with no block-level dedup, so a 1-byte edit to a 10 GB file adds 10 GB. Unchanged files add nothing but a directory entry. Each snapshot also adds one inode per directory.

Twenty. The rsync man page states the limit, and rsync 3.5.1 refuses a 21st with at most 20 --link-dest args may be specified and exit code 1. A snapshot script needs only one, the previous snapshot, because every snapshot already contains the full tree.

Neither is better. Snapshots made with --link-dest are plain browsable files with no encryption, compression, or block-level dedup. Restic and Borg encrypt, compress, and dedup across snapshots, but need their own tool to restore. Use --link-dest for a local disk or NAS, and restic or Borg for offsite copies.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
kube-vip: HA k3s Control Plane
Next Post
systemd Path Units: React to New Files

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts