Stop re-reading
the man page.
The model beneath the flags: derive the command, skip the man page.
1 · Mental model: rsync is a reconciler, not a copier
Every confusing thing about rsync becomes obvious once you accept one fact: rsync does not copy files. It makes a destination match a source, and it works hard to move as few bytes as possible while doing it. Copying is what it does when reconciling happens to require copying.
That distinction is not pedantry. It explains the behavior that trips people up:
- Why it appears to do nothing. Run the same command twice and the second run transfers no files. A copier would copy again. A reconciler sees the destination already matches and stops.
- Why
--deleteexists and is not the default. A destination with extra files does not match the source. rsync will not remove them unless you say so, because "match" is ambiguous and the destructive reading needs consent. - Why the trailing slash matters so much. Reconciling means answering "which destination path should hold this source?" The slash is how you answer.
- Why interrupting it is usually safe. Reconciliation is restartable. Run it again and it picks up the remaining difference.
Three processes, not one program
When you type one rsync command, up to three rsync processes cooperate. Knowing their names makes error messages readable, because rsync tags every error with the role that produced it — you have already seen [sender], [receiver], and [generator] in real output.
[receiver] tell you which role failed.The two questions rsync asks about every file
Internally, every file passes through two decisions, and almost every flag you will ever use tunes one of them:
1. Does this file need updating?
The default answer, called the quick check, is: update it if the size differs or the modification time differs. Nothing else. Not the contents.
Tuned by: --checksum, --size-only, --ignore-times, --update, --modify-window.
2. How little data can carry the update?
The delta-transfer algorithm finds which blocks the destination already has and sends only the rest.
Tuned by: --whole-file, --no-whole-file, --compress, --inplace, --block-size.
The misconception worth killing first
scp transfers what you name. rsync computes a difference and then applies it — which means it can delete, it can change permissions on directories you did not think you were touching, and it can decide your file needs no transfer at all. Treat every rsync invocation as a small, deliberate mutation of the destination tree, not as a copy. That is why --dry-run is in your muscle memory by the end of §7.
What you will be able to do
By the end of this guide you can, without consulting documentation:
- Perform: mirror a directory locally or over SSH, with or without deletion, and predict the resulting tree before you press Enter.
- Explain: why a file was or was not transferred, from the itemized output alone.
- Build: a hardlinked snapshot backup system with retention, scheduled under launchd or systemd.
- Recognize: which of the two rsyncs on your Mac you are running, and why it matters.
- Debug: permission failures, vanished files, filter rules that do not fire, and interrupted transfers.
- Evaluate: when rsync is the wrong tool, and what to reach for instead.
Sources: rsync(1) manual, 3.4.4 · The rsync algorithm (Tridgell & Mackerras technical report)
2 · Prerequisite floor
rsync assumes you are fluent in the shell's model of paths and files. It does not assume anything else. If the self-assessment below is comfortable, start at §3; if two or more items are shaky, the refreshers are short and they will save you an hour of confusion later.
Hard prerequisites
| You need to | Because rsync | Quick refresher |
|---|---|---|
| Read and write absolute vs relative paths | resolves both, and remote paths are relative to the remote home directory | pwd, cd .., ./x vs /x vs ~/x |
| Know what a directory's contents are, separately from the directory itself | encodes exactly that distinction in the trailing slash (§4) | ls dir lists contents; ls -d dir lists the directory |
Read ls -l output | preserves mode, owner, group, mtime, and symlink targets | -rw-r--r-- = type, then owner/group/other triads |
| Understand exit status | signals every failure mode through it, and your cron job depends on it | cmd; echo $? — 0 is success |
| Quote shell arguments | passes patterns to itself, not to the shell — unquoted * is a bug | --exclude='*.log' (quoted) vs --exclude=*.log (shell may expand) |
Soft prerequisites — helpful, learned just in time here
- SSH with key authentication. Needed for §10 onward. If
ssh yourhostalready works without a password prompt, you are set. If not, §10 includes the setup. - Hard links. Needed only for §14. Taught there from first principles; no prior exposure assumed.
- cron / launchd / systemd timers. Needed only for §15, which gives complete working unit files for both platforms.
Self-assessment
Answer these before continuing. Each links to where the answer lives if you want to check.
1. What is the difference between ls dir and ls -d dir? Why would rsync care?
ls dir lists what is inside; ls -d dir shows the directory entry itself. rsync makes the same distinction with a trailing slash: src/ means "the contents", src means "the directory named src". Getting this backwards produces the classic nested dst/src/… result. (§4)
2. A file's contents changed but its size and timestamp did not. Will a default rsync -a copy it?
No. The quick check compares size and mtime only. You need --checksum to catch it. This is verified with real output in §6.
3. What does echo $? print after a successful command? After a failed one?
0 after success, non-zero after failure. rsync uses roughly twenty distinct non-zero codes; §15 lists all of them and shows which ones a backup script should tolerate.
4. Why is --exclude=*.log (unquoted) risky?
The shell expands *.log against your current directory before rsync ever sees it. If a file named debug.log exists locally, rsync receives --exclude=debug.log and quietly excludes the wrong thing. Always quote pattern arguments. (§8)
5. Two files on disk share one inode. How much space do they use?
The space of one. That is a hard link, and it is the entire trick behind --link-dest snapshot backups. If this is new, §14 builds it up from scratch — do not skip ahead to it.
mktemp -d or a throwaway directory, and never touch anything you care about. Reading rsync and running rsync produce different knowledge; only one of them survives to the moment you actually need it. A realistic pace is three sections plus their labs per sitting, with the quiz (§Quiz) used as spaced review a day later.
Sources: GNU Bash manual — pathname expansion, quoting, exit status · rsync(1) — USAGE
3 · Install & verify: you probably have two rsyncs
On macOS this is not a formality. The rsync that ships with the system is a different program, written by different people, speaking an older protocol, missing flags this guide relies on. Finding out which one you are running takes ten seconds and prevents a whole category of "that flag doesn't work" confusion.
macOS: the openrsync trap
Ask any Mac what its rsync is:
# On macOS 15.6, verified 2026-08-06
/usr/bin/rsync --version
openrsync: protocol version 29
rsync version 2.6.9 compatible
That is openrsync, an independent reimplementation originating in OpenBSD, shipped by Apple in place of the Samba rsync. It is not rsync 2.6.9; it advertises compatibility with that protocol level. Meanwhile, if you have installed rsync from Homebrew:
/usr/local/bin/rsync --version | head -3
rsync version 3.4.4 protocol version 32
Copyright (C) 1996-2026 by Andrew Tridgell, Wayne Davison, and others.
Web site: https://rsync.samba.org/
Two programs, same name, protocol 29 versus 32. Which one runs depends entirely on your PATH order. Check with which -a rsync — it lists every match in order, and the first one wins.
What openrsync actually cannot do
Verified by running each flag against openrsync on macOS 15.6:
| Capability | rsync 3.4.4 | Apple's openrsync |
|---|---|---|
-a, -v, --delete, --exclude | yes | yes — the everyday core works |
--link-dest (snapshot backups) | yes | yes — verified to produce real shared inodes |
-i / --itemize-changes | yes — 11-character codes | yes — but in the older 9-character protocol-29 format (>f+++++++, no slots for ACL/xattr) |
--info=progress2 | yes | rsync: unrecognized option `--info=progress2' |
-X / --xattrs | yes | rsync: invalid option -- X |
--zc=zstd and modern compression | zstd, lz4, zlibx, zlib | rsync: unrecognized option `--zc=zstd' |
-v output wording | sending incremental file list | Transfer starting: 7 files |
PATH, man rsync on macOS 15.6 resolves to /usr/share/man/man1/openrsync.1. Confirm it yourself with man -w rsync. Consequence: you look up -a, read "Shorthand for -Dgloprt", and get openrsync's definition — while the rsync you are running documents it as -rlptgoD with a paragraph of caveats about ACLs and hardlinks. Read the right page explicitly:
man "$(brew --prefix rsync)/share/man/man1/rsync.1"
Or make it permanent by putting Homebrew's man directory first in MANPATH.
Install the real thing
# macOS — Homebrew
brew install rsync
which -a rsync # Homebrew's path must come first
rsync --version | head -1
rsync version 3.4.4 protocol version 32
On Apple Silicon, Homebrew installs under /opt/homebrew; on Intel, under /usr/local. The transcripts in this guide come from an Intel-layout install, so paths read /usr/local/…. Substitute brew --prefix if yours differs.
Ubuntu and WSL
sudo apt update && sudo apt install rsync
rsync --version | head -1
What you get depends on the release, and the spread is wide enough to matter:
| Ubuntu series | rsync version | Practical consequence |
|---|---|---|
| 22.04 LTS · Jammy Jellyfish | 3.2.7-0ubuntu0.22.04.7 | Has zstd, xattrs, --mkpath, --stop-at. Security fixes backported; feature set is 3.2-era. |
| 24.04 LTS · Noble Numbat | 3.2.7-1ubuntu1.5 | Same feature set as 22.04. If your WSL is a few years old, this is probably you. |
| 26.04 LTS · Resolute Raccoon | 3.4.1+ds1-7ubuntu0.3 | Post-CVE-2024 rewrite. Close enough to this guide that everything here applies. |
| 26.10 development · Stonking Stingray | 3.4.4+ds1-1build1 | Matches this guide exactly. |
| 20.04 LTS · Focal Fossa | 3.1.3-8ubuntu0.9 | Pre-3.2: no zstd, no --mkpath, no --stop-at, older checksum set. |
an option was specified that is supported by the client and not by the server — exit code 4. The fix is to drop the flag, not to upgrade in a panic.
WSL: the filesystem boundary is the real issue
Inside WSL, rsync is ordinary Linux rsync and behaves exactly as documented — as long as both sides live in the Linux filesystem. The moment a path crosses into /mnt/c/…, you are on the Windows drive through a translation layer, and three things change:
- Permissions are synthesized. NTFS has no Unix mode bits. Preserving them with
-aproduces noise at best and permission errors at worst. Use-rltplus explicit--no-perms --no-owner --no-group, or--chmodto impose a mode you choose. - Timestamps can be coarse. If the destination filesystem stores mtimes at lower resolution, every run sees a difference and re-copies everything.
--modify-window=1(or-@1) tells rsync to treat timestamps within N seconds as equal (§6). This is the standard fix for FAT/exFAT targets too. - It is slow. Cross-boundary I/O in WSL goes through a file-server protocol, not a direct filesystem. A tree that syncs in two seconds inside
~can take a minute under/mnt/c. Keep working sets on the Linux side and sync outward deliberately.
# WSL → Windows drive: don't fight NTFS over ownership
rsync -rlt --no-perms --no-owner --no-group --modify-window=1 \
--delete ~/project/ /mnt/c/Users/you/project/
\\wsl$\Ubuntu\home\you. Do not rsync into that path from a Windows-side tool if you can avoid it — run rsync inside WSL and let it write to /mnt/c. One translation layer is enough.
Goal: never again be surprised by a flag that "doesn't exist".
-
List every rsync on your
PATH, in priority order:which -a rsyncExpect: on a Mac with Homebrew rsync installed, two lines —
/usr/local/bin/rsyncthen/usr/bin/rsync. In WSL, one line:/usr/bin/rsync. -
Print the version of the one that wins:
rsync --version | head -1Expect:
rsync version 3.4.4 protocol version 32. If instead you seeopenrsync: protocol version 29, yourPATHfavors Apple's build — fix that before continuing. -
Find out which man page you would actually read:
man -w rsyncExpect (macOS):
/usr/share/man/man1/openrsync.1— the wrong page, even when the right binary is first. On Ubuntu/WSL you get/usr/share/man/man1/rsync.1.gz, which is correct. -
Check the capability line, which tells you what this build was compiled with:
rsync --version | sed -n '4,12p'Expect: a
Capabilities:block namingACLs, xattrs, … crtimes, aChecksum listincludingxxh128, and aCompress listincludingzstd. Anything missing here is a flag that will fail later no matter what the docs say.
Checkpoint: you can state your rsync's version, its protocol number, and whether it supports xattrs — from memory of what you just ran.
Sources: rsync downloads & release history · Ubuntu rsync package versions · openrsync(1)
4 · The trailing slash, settled forever
This is rsync's most famous confusion and it has exactly one rule. The rule is not "remember which way it goes." The rule is that a trailing slash on the source means "the contents of this directory" and its absence means "this directory". Everything else follows.
Proof, from a real run
Same source tree, same destination, one character apart:
# WITHOUT the slash — "copy the directory src"
rsync -av src dst/
sending incremental file list
src/
src/a.txt
src/b.txt
src/docs/
src/docs/note.md
find dst
dst
dst/src ← src itself landed inside dst
dst/src/a.txt
dst/src/b.txt
dst/src/docs
dst/src/docs/note.md
# WITH the slash — "copy the contents of src"
rsync -av src/ dst/
sending incremental file list
a.txt
b.txt
docs/
docs/note.md
find dst
dst
dst/a.txt ← contents landed directly in dst
dst/b.txt
dst/docs
dst/docs/note.md
Notice the verbose output already told you before find did. With the slash, paths are printed relative to the source; without it, every path is prefixed with src/. Read the first line of file output and you know which behavior you are getting — that is the habit worth building.
The destination slash does nothing (for directory sources)
Half the internet's advice hedges here. It does not need to. Verified:
rsync -a src/ d1 # no trailing slash on dest
rsync -a src/ d2/ # trailing slash on dest
find d1 d2
d1
d1/f.txt
d2
d2/f.txt ← identical
rsync -a src/f.txt out → out is a FILE (a copy of f.txt)
rsync -a src/f.txt out2/ → out2 is a DIRECTORY containing f.txt
Both verified. This is the only asymmetry; for directory sources the destination slash is decoration.
Interactive: predict the tree
Toggle the slashes and watch the destination change. Then close your eyes, guess, and check.
source: src/
src/
├── a.txt
├── b.txt
└── docs/
└── note.md
destination after the run
rsync -a src/ dst/ reads "sync the contents of src into dst." rsync -a src dst/ reads "sync src into dst." You already know which one you want; you just needed the sentence.
Where this bites in real life
The idempotency trap
Forgetting the slash and running twice does not create dst/src/src — it creates dst/src and then reconciles it. So the mistake is quiet. You discover it a week later when the backup is one level deeper than every script expects.
The --delete amplifier
With --delete, the slash mistake becomes destructive. rsync -a --delete src dst/ makes dst contain only src/, deleting everything else already in dst. Always dry-run a --delete command you have just edited. (§9)
-
Build a throwaway tree:
cd "$(mktemp -d)" && mkdir -p src/docs dst printf 'alpha\n' > src/a.txt printf 'note\n' > src/docs/note.md -
Run it the wrong way first — the mistake is more instructive than the fix:
rsync -av src dst/ && find dstExpect: transferred paths printed as
src/a.txt, andfindshowingdst/src/a.txt. -
Clear and do it right:
rm -rf dst && mkdir dst rsync -av src/ dst/ && find dstExpect: paths printed as bare
a.txtanddocs/note.md;findshowsdst/a.txt. -
Prove the destination slash is irrelevant here:
rsync -a src/ d1 && rsync -a src/ d2/ && diff -r d1 d2 && echo IDENTICALExpect:
IDENTICAL, with no output fromdiff. -
Now the single-file asymmetry:
rsync -a src/a.txt out && test -f out && echo "out is a FILE" rsync -a src/a.txt out2/ && test -d out2 && echo "out2 is a DIRECTORY"Expect: both echo lines print.
Checkpoint: without running anything, state what rsync -a ~/photos ~/backup/ produces versus rsync -a ~/photos/ ~/backup/. If you hesitated, redo step 2.
5 · Anatomy of a command: the eleven flags that matter
rsync 3.4.4 documents over two hundred options. You will use eleven of them regularly and about fifteen more occasionally. The rest exist for mirror operators, distribution builders, and people recovering from filesystems that no longer ship. This section teaches the eleven and, more importantly, how to compose them.
The shape
Either SRC or DEST may carry a host: prefix, never both. That single constraint defines rsync's whole network model, and §10 unpacks it.
-a is not magic, it is an abbreviation
The archive flag is where most people stop reading, which is a shame, because knowing its expansion tells you exactly what it does not do. From the rsync 3.4.4 manual:
# --archive, -a → equivalent to -rlptgoD
-r --recurse descend into directories
-l --links copy symlinks as symlinks
-p --perms preserve permissions
-t --times preserve modification times
-g --group preserve group
-o --owner preserve owner (silently ignored without privilege — §12)
-D --devices --specials preserve device and special files
-a deliberately leaves out
The manual is explicit: -a does not include ACLs (-A), extended attributes (-X), access times (-U), creation times (-N), or hard-link detection (-H). If you assumed "archive" meant "everything", §12 is the correction — and on macOS the omission of -X silently drops Finder tags and Spotlight metadata.
Note also: openrsync documents -a as -Dgloprt. Same seven letters, different order, and it is a different program's promise. One more reason to read the right man page (§3).
The working set
| Flag | What it does | When you reach for it |
|---|---|---|
-a | recurse + preserve the usual metadata | Always. This is the baseline, not an option. |
-v | name each transferred file | Interactive runs. Omit in cron unless you want mail. |
-n / --dry-run | decide everything, change nothing | Before any --delete or any command you just edited. |
-i / --itemize-changes | emit a change code per file | Whenever "why did it do that?" comes up. (§7) |
--delete | remove destination files absent from source | Mirroring. Never without a dry run first. (§9) |
--exclude=PAT | skip matching paths | Constantly. Quote the pattern. (§8) |
-z / --compress | compress the stream in flight | Slow or metered links only. (§13) |
-P | --partial --progress together | Big transfers you might interrupt. |
-h | human-readable byte counts | Always, honestly. Costs nothing. |
-e ssh … | choose/configure the remote shell | Non-default ports, identity files, jump hosts. (§10) |
--stats | print a transfer summary | Learning, benchmarking, and proving delta worked. |
The universal starting pattern
# Say this to yourself, then edit down. It is safe by construction:
rsync -avhn --delete SRC/ DEST/ # look
rsync -avh --delete SRC/ DEST/ # leap (drop the n)
Type the dry run, read it, then press ↑ and delete one character. That two-step is the single highest-value habit in this guide, and it costs about four seconds.
Interactive: compose a command
Toggle flags and read what the command actually means. The expansion line shows what -a unfolds into, so you can see when a flag you added is already implied.
-avh as your reflex and add from there. Do not reach for -z by habit — on a LAN or a local disk it costs CPU and saves nothing, and on already-compressed data (photos, video, .gz) it is pure loss. Do not reach for --checksum by habit either; it reads every byte on both sides, which is precisely the work rsync exists to avoid.
Sources: rsync(1) — OPTIONS SUMMARY
6 · The quick check and the delta algorithm
Two mechanisms decide everything rsync does. The quick check decides whether a file is transferred. The delta-transfer algorithm decides how much of it crosses the wire. They are independent, they are both defaults, and both have a switch.
Mechanism 1: the quick check
Before transferring anything, the generator compares each source file against its destination counterpart. The default test — the "quick check" — is:
It is a very good heuristic. It is also falsifiable, and here is the falsification, run for real:
# Two files, same length, different content, identical timestamps
printf 'same size!\n' > src/f.txt # synced to dst
printf 'SAME SIZE!\n' > src/f.txt # edited: 10 bytes → 10 bytes
touch -t 202608061200 src/f.txt dst/f.txt
rsync -avi src/ dst/
sent 75 bytes received 12 bytes 174.00 bytes/sec ← nothing transferred!
rsync -avic src/ dst/ # -c is --checksum
>fc........ f.txt
sent 149 bytes received 35 bytes 368.00 bytes/sec
cat dst/f.txt
SAME SIZE!
The c in >fc........ is rsync telling you the checksum differed — a code you can only see because --checksum made it look. §7 decodes the rest of that string.
Tuning the quick check
| Flag | Changes the test to | Cost / use |
|---|---|---|
-c / --checksum | compare a full-file checksum on files whose size already matches (algorithm auto-negotiated; xxh128 between 3.2+ peers, MD5 with older ones) | Reads every byte on both sides. Use to verify a migration, not routinely. |
--size-only | size alone; ignore timestamps | For destinations with untrustworthy clocks. Misses same-size edits. |
-I / --ignore-times | never skip; always transfer | Forces the delta algorithm to run on everything. Rarely what you want. |
-u / --update | skip files that are newer on the receiver | Protects destination edits. Verified below. |
--modify-window=N | treat mtimes within N seconds as equal | FAT/exFAT/NTFS-over-WSL destinations. Usually 1. |
# -u in action: destination file is newer, so it survives
rsync -avi -u up/src/ up/dst/
sent 77 bytes received 12 bytes
cat up/dst/f.txt
NEWER ON DEST ← preserved
# without -u, the source wins unconditionally
rsync -avi up/src/ up/dst/
>f.st...... f.txt
cat up/dst/f.txt
old ← clobbered
Two neighbours worth knowing: --existing and --ignore-existing
These do not tune the comparison — they restrict which files are eligible at all. They are exact opposites and their names are unhelpfully similar, so learn them as a pair. Source has have.txt (also at the destination) and new.txt (not):
# --existing: update only what is already there. Never create.
rsync -ain --existing src/ dst/
.d..t...... ./
>f.st...... have.txt ← new.txt is not created
# --ignore-existing: create only what is missing. Never update.
rsync -ain --ignore-existing src/ dst/
>f+++++++++ new.txt ← have.txt is left alone
--existing is for pushing a config change to machines that already opted in. --ignore-existing is for topping up a destination whose copies you must not disturb — filling gaps in a media library, or seeding without overwriting local edits.
Mechanism 2: the delta-transfer algorithm
Once rsync decides a file needs updating, it does not necessarily send the file. This is the algorithm rsync is named for, and the reason it exists.
Proof, both directions
A 10 MB file, copied, then 50 bytes overwritten in the middle. Over a remote transport:
# first copy — everything is literal
Literal data: 10,485,760 bytes
Matched data: 0 bytes
Total bytes sent: 10,488,443
speedup is 1.00
# after changing 50 bytes — delta kicks in
Literal data: 1,152 bytes
Matched data: 10,484,608 bytes
Total bytes sent: 14,263
Total bytes received: 19,505
speedup is 310.52
The same change on a purely local copy sends everything:
# local src → local dst, default behavior
Literal data: 10,485,760 bytes
Matched data: 0 bytes
speedup is 1.00
# same local copy with --no-whole-file
Literal data: 1,152 bytes
Matched data: 10,484,608 bytes
speedup is 310.54
--whole-file is implied. The reasoning is sound: computing deltas requires reading both files completely, and if both live on disks you own, reading 20 MB to avoid writing 10 MB is a loss. It stops being sound when the "local" destination is a slow network mount, which is exactly when to add --no-whole-file.
The simplified model versus the complete one
Earlier this guide said "the delta algorithm sends only changed blocks." The complete model adds three qualifications you now have the context for:
- Block size is chosen from the file size (or forced with
-B). Very large files get larger blocks, so tiny scattered edits can still transmit more than you expect. - The receiver must have an old copy to describe. A brand-new file has no basis, so it is always fully literal — unless you supply one with
--fuzzy,--link-dest, or--compare-dest(§14). - The
speedupfigure counts file bytes versus wire bytes, including protocol overhead. It is a useful ratio, not a benchmark.
-
Set up a same-size edit:
cd "$(mktemp -d)" && mkdir -p src dst printf 'same size!\n' > src/f.txt rsync -a src/ dst/ printf 'SAME SIZE!\n' > src/f.txt touch -t 202608061200 src/f.txt dst/f.txt -
Confirm rsync refuses to notice:
rsync -avi src/ dst/ ; cat dst/f.txtExpect: no itemized line for
f.txt, anddst/f.txtstill readingsame size!. This is not a bug; it is the documented quick check. -
Force a content comparison:
rsync -avic src/ dst/ ; cat dst/f.txtExpect:
>fc........ f.txtandSAME SIZE!. -
Now measure delta transfer. Build a 10 MB file and sync it, then change 50 bytes in the middle:
head -c 10485760 /dev/zero | tr '\0' 'A' > src/big.dat rsync -a --stats src/big.dat dst/ | grep -E 'Literal|Matched' sleep 2 printf 'Z%.0s' {1..50} | dd of=src/big.dat bs=1 seek=5000000 conv=notrunc status=noneExpect:
Literal data: 10,485,760 bytes/Matched data: 0 byteson the first sync. -
Sync again twice — once with the local default, once forcing delta:
rsync -a --stats src/big.dat dst/ | grep -E 'Literal|Matched|speedup' printf 'Q%.0s' {1..50} | dd of=src/big.dat bs=1 seek=6000000 conv=notrunc status=none rsync -a --stats --no-whole-file src/big.dat dst/ | grep -E 'Literal|Matched|speedup'Expect: the first shows
Literal data: 10,485,760andspeedup is 1.00; the second shows roughlyLiteral data: 1,152,Matched data: 10,484,608,speedup is 310-ish. Your exact literal figure depends on block size, which scales with file size.
Checkpoint: explain, in one sentence each, why step 2 transferred nothing and why step 5's two runs differed by 300×.
Sources: The rsync algorithm — technical report · rsync(1) — --checksum, --whole-file, --size-only
7 · Reading rsync's output
rsync's default verbose output tells you what it touched. The itemized output tells you why. Learning the eleven-character change string converts rsync from a black box into an instrument, and it takes about ten minutes.
The default: -v
rsync -av src/ dst/
sending incremental file list
a.txt
b.txt
docs/
docs/note.md
sent 304 bytes received 85 bytes 778.00 bytes/sec
total size is 16 speedup is 0.04
Three things worth naming. "sending incremental file list" means rsync is streaming the file list as it walks, not building it all up front — that is why big trees start transferring immediately. Directories appear with a trailing slash when they are created or their attributes change. speedup below 1.00 is normal for tiny transfers; protocol overhead dominates.
The instrument: -i
Every changed item gets an eleven-character code:
rsync -avi src/ dst/
.d..t...... ./
>f.st...... a.txt
.f...p..... b.txt
>f+++++++++ c.txt
cd+++++++++ docs/
cL+++++++++ link.lnk -> target.txt
*deleting b.txt
Read it positionally. Positions 1–2 are what and how; positions 3–11 are which attributes changed.
-i / --itemize-changesThe full position table
| Pos | Meaning | Values you will see |
|---|---|---|
| 1 | Update type / direction | > received by this side · < sent to the remote · c created locally (dir, symlink, device) · h became a hard link · . no transfer, attributes only · * message follows (e.g. *deleting) |
| 2 | File type | f file · d directory · L symlink · D device · S special |
| 3 | c | regular file's checksum differs (needs --checksum), or a symlink/device/special file's value changed. A brand-new item shows + here, like every other slot. |
| 4 | s | size differs |
| 5 | t | modification time differs and is being updated (T = set to transfer time instead) |
| 6 | p | permissions differ |
| 7 | o | owner differs (needs -o and privileges) |
| 8 | g | group differs |
| 9 | u/n/b | access time differs (u, needs -U/--atimes), create time differs (n, needs -N/--crtimes), or both (b). Without those flags: always . |
| 10 | a | ACL differs (needs -A) |
| 11 | x | extended attributes differ (needs -X) |
>f+++++++++ — a brand-new file; all nine attribute slots are + because there is nothing to compare against. >f.st...... — an existing file whose size and time changed, i.e. a normal edit. Once those two are instant recognition, everything else is a small deviation you can look up.
--dry-run and its one honest limitation
-n makes every decision and performs none of them. Combined with -i it is the best tool in the box:
rsync -ain --delete src/ dst/
*deleting b.txt
>f.st...... a.txt
created directory out — and then does not create it. Verified: after the run, ls -d out returns No such file or directory. Worse, because the directory does not exist, rsync cannot inspect what is inside it, so a dry run into a fresh destination shows every file as new even when a real run would find matches. Dry-run output is a plan, not a transcript.
Progress, for humans
| Option | Shows | Notes |
|---|---|---|
--progress | per-file percentage, rate, ETA | Noisy with many small files. |
--info=progress2 | one running total for the whole transfer | What you actually want. Pair with -h. Not in openrsync. |
-P | --partial --progress | The interactive big-transfer default. |
--stats | end-of-run summary incl. literal/matched | How you prove delta transfer worked. |
rsync -ah --info=progress2 src/ dst/
2.10M 100% 246.43MB/s 0:00:00 (xfr#3, to-chk=0/4)
xfr#3 is the third file actually transferred; to-chk=0/4 means zero of four remaining file-list entries left to examine. When to-chk stalls at a high number, rsync is still walking the tree, not stuck.
-
Fresh tree, first sync:
cd "$(mktemp -d)" && mkdir -p src/docs dst printf 'alpha\n' > src/a.txt ; printf 'beta\n' > src/b.txt printf 'note\n' > src/docs/note.md rsync -avi src/ dst/Expect:
>f+++++++++ a.txt,cd+++++++++ docs/, and.d..t...... ./for the destination root itself. -
Make three different kinds of change at once:
printf 'alpha CHANGED\n' > src/a.txt # content + size chmod 600 src/b.txt # permissions only printf 'gamma\n' > src/c.txt # brand new rsync -avi src/ dst/Expect exactly:
.d..t...... ./ >f.st...... a.txt .f...p..... b.txt >f+++++++++ c.txtRead each:
a.txttransferred with size and time changes;b.txtnot transferred at all (leading.) but its permissions were fixed;c.txtis new. -
Delete a source file and dry-run the deletion:
rm src/b.txt rsync -avin --delete src/ dst/Expect:
*deleting b.txtand a summary line ending(DRY RUN). -
Verify the dry run changed nothing, then commit:
ls dst/ ; rsync -avi --delete src/ dst/ ; ls dst/Expect:
b.txtpresent in the first listing, absent in the second.
Checkpoint: given .f...p....., say whether any bytes crossed the wire. (No — position 1 is .. Only the mode was adjusted.)
8 · Filter rules: the part everyone re-reads
Filter rules are rsync's second real language, and they are re-read every time because they have three behaviors that violate intuition: first match wins, directories are pruned before their contents are examined, and a pattern's anchoring changes with a single leading slash. Learn those three and the rest is syntax.
Rule 1: first match wins
rsync evaluates rules in the order given and stops at the first one that matches. Not most-specific-wins. Not last-wins. First. Verified:
# WRONG order — the exclude fires first, include never runs
rsync -ain --exclude='*.log' --include='debug.log' proj/ out/
(no line for debug.log — it was excluded)
# RIGHT order — the include is reached first and wins
rsync -ain --include='debug.log' --exclude='*.log' proj/ out/
>f+++++++++ docs/debug.log
--include adds files back."
It does not add anything. There is no set to add to — every file is included by default. --include means "if you reach this rule, stop and keep the file", and it only has an effect when it is positioned before an exclude that would otherwise catch the file. An --include with no exclude after it is a no-op.
Rule 2: directories are pruned, not searched
If a rule excludes a directory, rsync never descends into it — so a later include for something inside it can never fire. This is why the "include only .md files" idiom needs the seemingly redundant --include='*/':
rsync -ain --include='*/' --include='*.md' --exclude='*' proj/ out/
cd+++++++++ ./
cd+++++++++ .git/
cd+++++++++ build/
cd+++++++++ docs/
>f+++++++++ docs/readme.md
cd+++++++++ node_modules/
cd+++++++++ node_modules/pkg/
cd+++++++++ src/
Read it: --include='*/' keeps every directory so rsync can walk in; --include='*.md' keeps the files you want; --exclude='*' drops everything else. It works — but look at all those empty directories. Add -m (--prune-empty-dirs):
rsync -ainm --include='*/' --include='*.md' --exclude='*' proj/ out/
cd+++++++++ ./
cd+++++++++ docs/
>f+++++++++ docs/readme.md
This four-part incantation — -m, include dirs, include what you want, exclude the rest — is worth memorizing verbatim. It is the answer to "copy only the X files from this tree."
Rule 3: anchoring
| Pattern | Matches | Does not match |
|---|---|---|
build | build anywhere in the tree, at any depth | — |
/build | build only at the transfer root | src/build |
build/ | only when build is a directory | a file named build |
*.log | any .log at any depth (* stops at /) | — |
**/tmp | tmp at any depth (** crosses /) | — |
src/**.o | .o at any depth under src, including src/main.o | lib/x.o; note src/**/*.o would also miss src/main.o — ** cannot match nothing before a / |
The leading slash is relative to the transfer root, not your filesystem root and not your working directory. In rsync -a ~/proj/ backup:/srv/, the rule /build means ~/proj/build.
Where rules can live
On the command line
--exclude=PAT and --include=PAT. Fine for two or three rules. Always quote the pattern so the shell does not expand it.
In a file
--exclude-from=FILE, one pattern per line, # comments allowed. This is where a real project's rules belong — under version control, next to the deploy script.
As --filter rules
The full language: -f '- *.log' excludes, -f '+ keep.log' includes, -f 'P /uploads/' protects from deletion, -f 'H pattern' hides from the sender.
Per-directory, merged
-f 'dir-merge /.rsync-filter' — or its shorthand -F — reads a .rsync-filter file in each directory it visits, applying it to that subtree. Rules live with the content they govern.
--exclude-from, the practical default
cat excludes.txt
node_modules/
build/
*.log
.git/
rsync -ain --exclude-from=excludes.txt proj/ out/
cd+++++++++ ./
cd+++++++++ docs/
>f+++++++++ docs/readme.md
cd+++++++++ src/
>f+++++++++ src/main.c
Per-directory merge files
Drop a .rsync-filter into any directory and its rules govern that subtree:
cat proj/docs/.rsync-filter
- *.log
rsync -ain --filter='dir-merge /.rsync-filter' proj/ out/ # fresh out/
created directory out
cd+++++++++ ./
>f+++++++++ app.log ← root .log survives: rule is scoped to docs/
cd+++++++++ docs/
>f+++++++++ docs/.rsync-filter ← the rule file itself gets copied
>f+++++++++ docs/readme.md ← docs/debug.log was excluded
cd+++++++++ src/
>f+++++++++ src/main.c
Doubling the shorthand — -FF instead of -F — also excludes the filter files themselves from transfer:
rsync -ain -FF proj/ out/ # fresh out/
created directory out
cd+++++++++ ./
>f+++++++++ app.log ← root .log still survives
cd+++++++++ docs/
>f+++++++++ docs/readme.md ← no .rsync-filter, no debug.log
cd+++++++++ src/
>f+++++++++ src/main.c
--exclude-from= a version-controlled file for anything you run more than twice. Reserve -F for trees where different subdirectories genuinely need different rules and the rules should travel with the content — a documentation repo, a shared media library. Do not reach for --filter's full syntax until P (protect) is what you actually need; the short forms cover the rest.
Debugging rules that will not fire
Three moves, in order:
-ainand read the file list. If a file appears, no rule excluded it. If it is missing, some rule did.- Ask rsync to explain itself:
--debug=FILTER2prints each rule as parsed and names every path it hides and why. This is the fastest path from "why is this still copying" to an answer. (Level 1 is nearly silent; useFILTER2.) - Check for shell expansion. Run
echoin front of your command. If--exclude=*.logcame back as--exclude=app.log, your quoting is the bug, not your rule.
-
Build a realistic project tree:
cd "$(mktemp -d)" mkdir -p proj/src proj/build proj/node_modules/pkg proj/docs proj/.git printf 'code\n' > proj/src/main.c ; printf 'obj\n' > proj/build/main.o printf 'dep\n' > proj/node_modules/pkg/index.js printf 'doc\n' > proj/docs/readme.md ; printf 'log\n' > proj/app.log printf 'log2\n' > proj/docs/debug.log ; printf 'gitobj\n' > proj/.git/HEAD -
Exclude the obvious junk:
rsync -ain --exclude='node_modules' --exclude='*.log' proj/ out/Expect:
src/main.c,docs/readme.md,build/main.o,.git/HEAD— and nonode_modulesor.loganywhere. -
Demonstrate first-match-wins by running the same two rules in both orders:
rsync -ain --exclude='*.log' --include='debug.log' proj/ out/ | grep debug.log rsync -ain --include='debug.log' --exclude='*.log' proj/ out/ | grep debug.logExpect: nothing from the first command;
>f+++++++++ docs/debug.logfrom the second. -
Copy only Markdown, with and without pruning:
rsync -ain --include='*/' --include='*.md' --exclude='*' proj/ out/ rsync -ainm --include='*/' --include='*.md' --exclude='*' proj/ out/Expect: the first lists eight lines including empty
node_modules/pkg/; the second lists exactly three —./,docs/,docs/readme.md. -
Scope a rule to one subtree with a merge file:
printf -- '- *.log\n' > proj/docs/.rsync-filter rsync -ain -F proj/ out/ | grep -E 'app.log|debug.log|rsync-filter' rsync -ain -FF proj/ out/ | grep -E 'app.log|debug.log|rsync-filter'Expect:
-Fkeepsapp.log(root scope, untouched by the docs rule) and copiesdocs/.rsync-filter;-FFkeepsapp.logbut omits the filter file itself. Neither copiesdocs/debug.log. -
Ask rsync to show its reasoning:
rm proj/docs/.rsync-filter rsync -ain --debug=FILTER2 --exclude='node_modules' --exclude='*.log' proj/ out/ 2>&1 | head -11Expect (paths abbreviated):
[client] add_rule(- node_modules) [client] add_rule(- *.log) [sender] pushing local filters for …/proj/ [sender] hiding directory node_modules because of pattern node_modules [sender] hiding file app.log because of pattern *.log [sender] pushing local filters for …/proj/.git/ [sender] pushing local filters for …/proj/build/ [sender] pushing local filters for …/proj/docs/ [sender] hiding file docs/debug.log because of pattern *.loghiding … because of pattern …is the line you came for: it names the file and the exact rule that caught it.
Checkpoint: from memory, write the command that copies only .jpg and .png files out of a deep tree, with no empty directories in the result.
Sources: rsync(1) — FILTER RULES, INCLUDE/EXCLUDE PATTERN RULES, MERGE-FILE FILTER RULES
9 · Deleting safely
The wrong question is "is --delete dangerous?" The right question is "what exactly does rsync consider extraneous?" Answer that and --delete becomes ordinary. Guess at it and you will eventually remove something you wanted. One fear you can drop now: --delete only ever touches the receiving side. Your source is never at risk — unless you explicitly ask with --remove-source-files, covered below.
What --delete means
It means: on the receiving side, remove files that are not in the sending side's file list. The emphasis matters. "Not in the file list" is not the same as "not in the source directory", because filter rules and --exclude shape the file list before deletion ever looks at it.
rm src/b.txt
rsync -avi --delete --dry-run src/ dst/
sending incremental file list
*deleting b.txt
sent 211 bytes received 30 bytes 482.00 bytes/sec
total size is 33 speedup is 0.14 (DRY RUN)
The five deletion timings
| Option | When deletions happen | Choose it when |
|---|---|---|
--delete | alias for --delete-during in 3.x | Default. Just use this. |
--delete-before | whole destination scanned and pruned first | Destination is nearly full and you need the space before writing. |
--delete-during | per-directory, as the transfer walks | Lowest memory, no up-front scan. The modern default. |
--delete-delay | computed during, applied at the end | Want deletions batched but not a pre-scan. |
--delete-after | after all transfers complete | Publishing a site: new files land before old ones vanish. |
--delete for backups and mirrors. Use --delete-after for deploys, so a failed transfer leaves the old content intact rather than a half-emptied directory. Those two cover essentially every real case; the other three are for situations you will recognize when you are in them.
The guard rail: --max-delete
A source that failed to mount looks exactly like a source where you deleted everything. --max-delete=N is the cheap insurance:
rsync -avi --delete --max-delete=2 src/ dst/
*deleting f3.txt
*deleting f2.txt
Deletions stopped due to --max-delete limit (1 skipped)
sent 95 bytes received 39 bytes 268.00 bytes/sec
rsync error: the --max-delete limit stopped deletions (code 25) at main.c(1356) [sender=3.4.4]
echo $?
25
Note the behavior precisely: it deletes up to the limit, then stops and exits 25. It does not roll back the deletions it already made. Treat exit 25 in a scheduled job as "investigate now" — that is exactly the signal you wanted.
rsync -a --delete /Volumes/Backup/ ~/Documents/ with the volume unmounted does not error. /Volumes/Backup/ exists as an empty directory, so rsync faithfully makes your Documents folder match it — by deleting everything. Three defenses, in order of value:
- Always pass
--max-deleteon any automated--deletejob. - Guard the script:
mountpoint -q /mnt/backup || exit 1on Linux;[ -f /Volumes/Backup/.backup-marker ] || exit 1on macOS, where you place a marker file on the volume itself. - Add
--dry-runto the command in your editor and remove it as the last edit before saving.
Safety nets that keep the old bytes
--backup turns overwrite-and-delete into move-aside. With --backup-dir, the displaced files land in a parallel tree rather than littering the destination with ~ suffixes:
rsync -avi --backup --backup-dir=../attic --suffix='' src/ dst/
sending incremental file list
>f..t...... f.txt
cat attic/f.txt
original ← the pre-overwrite content, preserved
--backup-dir is relative to the destination, so ../attic means a sibling of dst. Combine with a dated directory for a poor man's version history:
rsync -a --delete --backup --backup-dir="../attic/$(date +%F)" src/ dst/
--remove-source-files
This deletes from the sending side after a successful transfer — an rsync-flavored mv that verifies before removing. Verified caveat: it removes files only, never directories. After the run your source tree is an empty skeleton of directories:
rsync -av --remove-source-files src/ dst/
find src
src
src/d ← directories remain, files are gone
Follow with find src -type d -empty -delete if you want the skeleton gone too.
Deletion and filters interact — badly, if you are careless
An excluded file is not in the file list, so from --delete's point of view it is extraneous at the destination and gets removed. That is usually wrong: you excluded node_modules from the transfer, not from existence. Three flags resolve the ambiguity:
| Flag | Effect on excluded files at the destination |
|---|---|
--delete-excluded | Delete them. Use when the exclusion means "this must not exist here". |
--exclude=PAT (plain) | Excluded files are protected from deletion on the receiver — the exclusion applies to both sides. This is the default and it is usually what you want. |
--filter='protect PAT' (-f 'P PAT') | Explicitly shield destination paths from --delete without excluding them from transfer logic. |
Verified, with a destination that holds app.log (matching the exclude) and junk.txt (not matching):
rsync -avi --delete --exclude='*.log' src/ dst/
*deleting junk.txt
>f+++++++++ keep.txt
ls dst/
app.log keep.txt ← the excluded file survived
rsync -avi --delete --delete-excluded --exclude='*.log' src/ dst/
*deleting app.log
ls dst/
keep.txt ← now it is gone
The practical rule: if you want the destination to keep something rsync never sends — a .env, an uploads directory, a build cache — say so explicitly with a protect rule rather than relying on exclusion semantics you would have to re-derive next year.
# Deploy: replace the code, never touch runtime state
rsync -av --delete-after \
--filter='P /uploads/' --filter='P /.env' \
--exclude='.git/' ./build/ web:/srv/app/
-
Build a mirror with five files:
cd "$(mktemp -d)" && mkdir -p src dst for i in 1 2 3 4 5; do printf "x\n" > src/f$i.txt; done rsync -a src/ dst/ && ls dst/ -
Remove three sources and preview the damage:
rm src/f1.txt src/f2.txt src/f3.txt rsync -avin --delete src/ dst/Expect: three
*deletinglines and(DRY RUN)in the summary. -
Now cap the damage and watch rsync refuse:
rsync -avi --delete --max-delete=2 src/ dst/; echo "exit=$?"Expect: two deletions, then
Deletions stopped due to --max-delete limit (1 skipped), thenrsync error: the --max-delete limit stopped deletions (code 25)andexit=25. Confirm withls dst/that one of the three survived. -
Prove the empty-source catastrophe in a safe sandbox — read this step before running it:
mkdir empty rsync -avin --delete empty/ dst/Expect: a
*deletingline for every remaining file indst. Nothing happens because of-n. This is precisely what an unmounted backup volume produces, and precisely why--max-deletebelongs in every automated job. -
Add the safety net and repeat for real:
rsync -avi --delete --backup --backup-dir=../attic --suffix='' src/ dst/ ls attic/Expect: the deleted files reappear under
attic/instead of being gone.--backupcatches deletions, not only overwrites.
Checkpoint: write, from memory, a --delete command that refuses to remove more than 20 files and keeps a dated copy of anything it removes.
Sources: rsync(1) — --delete*, --max-delete, --backup-dir, --remove-source-files
10 · Remote over SSH: what actually happens
rsync has no network code for the SSH case. It runs ssh host rsync --server … and talks to that process over the pipe. Once you have seen the command it constructs, remote rsync stops being a separate topic and becomes local rsync with a longer pipe.
The syntax
# push: local → remote
rsync -avh ~/site/ web.example.com:/srv/site/
# pull: remote → local
rsync -avh web.example.com:/srv/site/ ~/site/
# with a user, and a path relative to that user's home
rsync -avh ~/notes/ deploy@web.example.com:notes/
Exactly one side may carry a host: prefix. rsync cannot copy remote-to-remote directly; it would need to be on one of the machines. (You can fake it by running rsync on one of the hosts over SSH, but that is you orchestrating, not rsync.)
host:notes/ means ~/notes/ on the remote, not /notes/. This is the same rule as scp and it is the source of the occasional "where did my files go?" — they went to the deploy user's home directory.
What rsync sends to the far side
Substitute a logging script for ssh with -e and rsync will show you its hand. These transcripts come from exactly that — a stand-in remote shell that prints its arguments and then runs the command locally. The argv is genuine; the transport is a pipe rather than a network:
# push
rsync -av -e ./logrsh src/ fakehost:/tmp/dst/
REMOTE-SHELL ARGV: fakehost rsync --server -vlogDtpre.iLsfxCIvu . /tmp/dst/
# pull — note --sender appears
rsync -av -e ./logrsh fakehost:/tmp/src/ dst/
REMOTE-SHELL ARGV: fakehost rsync --server --sender -vlogDtpre.iLsfxCIvu . /tmp/src/
# add -z --partial and watch the flags follow
REMOTE-SHELL ARGV: fakehost rsync --server -vlogDtprze.iLsfxCIvu --partial . /tmp/dst2/
Three things become obvious at once:
rsyncmust exist on the remote, in the remote login shell'sPATH. That is the entire dependency. No daemon, no port, no configuration.- Your flags are forwarded.
-zbecamezin-vlogDtprze…;--partialwas passed through verbatim. A flag the remote does not understand fails there, giving exit code 4. - The trailing
e.iLsfxCIvuis a capability string — the client telling the server which protocol features it supports. Apple's openrsync sends separate flags instead (--server -g -l -o -p -D -r -t -v --dirs), which is one visible sign you are talking to a different implementation.
rsync --server. That is why remote wildcards expand remotely, why a noisy .bashrc can corrupt the protocol stream, and why --rsync-path exists.Setting up key authentication
rsync over SSH with a password prompt is unusable in scripts and irritating interactively. Fix it once:
# create a key if you don't have one
ssh-keygen -t ed25519 -C "rsync $(hostname)"
# install it on the remote (Ubuntu/WSL ships ssh-copy-id; macOS does not)
ssh-copy-id user@host
# macOS equivalent:
ssh user@host 'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys' < ~/.ssh/id_ed25519.pub
# verify: this must print OK with no prompt
ssh -o BatchMode=yes user@host 'echo OK; command -v rsync'
That second command is the real test. If it prints OK and a path to rsync, every rsync command in this section will work. If it prints Permission denied (publickey), fix SSH before blaming rsync.
Configuring the transport with -e
| Need | Command |
|---|---|
| Non-standard port | rsync -av -e 'ssh -p 2222' src/ host:dst/ |
| Specific key | rsync -av -e 'ssh -i ~/.ssh/deploy_ed25519' src/ host:dst/ |
| Jump host | rsync -av -e 'ssh -J bastion.example.com' src/ host:dst/ |
Remote rsync not on PATH | rsync -av --rsync-path=/usr/local/bin/rsync src/ host:dst/ |
| Remote write needs root | rsync -av --rsync-path='sudo rsync' src/ host:/etc/app/ |
~/.ssh/config instead
Every -e 'ssh …' above is configuration that belongs in one place:
Host web
HostName web.example.com
User deploy
Port 2222
IdentityFile ~/.ssh/deploy_ed25519
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 60
Then rsync -av src/ web:/srv/site/ just works — and the ControlMaster lines make repeated runs reuse one connection instead of renegotiating SSH every time, which is a real speedup for many-small-file transfers.
Remote paths go through a shell — plan accordingly
Because the remote side runs your command through a login shell, wildcards on the remote are expanded there:
rsync -av 'host:/srv/logs/*.txt' ./
receiving incremental file list
a.txt
c.txt
Quote it locally (so your shell leaves it alone) and the remote shell expands it. That is a feature until it is not: an unmatched glob produces a confusing error, and spaces need care. Since 3.2.4, rsync escapes spaces and other specials for you by default (--old-args reverts to the raw behavior) — on an older client like Ubuntu 20.04's 3.1.3, you must double-quote remote paths yourself:
# rsync 3.4.4 escapes the space itself
REMOTE-SHELL ARGV: fakehost rsync --server -vlogDtpre.iLsfxCIvu . /tmp/dst/a\ b/
# with -s (--secluded-args) the paths leave the command line entirely
rsync -avs -e ./logrsh "my docs/" "fakehost:/tmp/dst/c d/"
REMOTE-SHELL ARGV: fakehost rsync --server -svlogDtpre.iLsfxCIvu ← no paths at all
-s sends filenames over the protocol instead of the command line, so the remote shell cannot mangle them. Use it when paths contain shell metacharacters — and remember that it also disables remote wildcard expansion, because there is no longer a command line for the shell to expand.
.bashrc prints anything — a banner, a fortune, an "unread mail" notice — that text lands in rsync's protocol stream and corrupts it. Symptoms are protocol version mismatch -- is your shell clean? or connection unexpectedly closed. Test with ssh host 'true' and confirm it produces zero output. Fix by guarding the interactive parts of the remote rc file:
case $- in *i*) ;; *) return;; esac # put this near the top of ~/.bashrc
Errors you will actually see
# remote rsync missing or wrong path
bash: /nonexistent/rsync: No such file or directory
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: remote command could not be run (code 126) at io.c(232) [sender=3.4.4]
Two neighbouring exit codes cover this failure: 127, remote command not found, and 126, remote command found but could not be run. Which one you get depends on how the remote shell reports the failure — do not memorize the split; treat either as "the far side has no runnable rsync where I pointed". Check ssh host 'command -v rsync' and reach for --rsync-path= if the binary lives somewhere unusual — Homebrew on the far side is a common cause.
Bandwidth and metered links
# cap at ~2 MB/s so you can keep using the connection
rsync -avh --bwlimit=2000 --info=progress2 big/ host:/srv/big/
--bwlimit is in KiB/s by default; suffixes work (--bwlimit=2m). Pair with --partial so an interrupted transfer does not start over — §13 shows exactly what that buys.
Using localhost as the "remote" exercises the entire SSH code path with zero risk and no second machine. Everything you learn transfers verbatim to a real host.
-
Enable SSH to yourself. On macOS: System Settings → General → Sharing → turn on Remote Login. In WSL/Ubuntu:
sudo apt install openssh-server && sudo service ssh start. -
Authorize your own key and verify a silent, password-free login:
ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 # skip if you have one cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys ssh -o BatchMode=yes localhost 'echo OK; command -v rsync'Expect:
OKfollowed by a path such as/usr/local/bin/rsync. If you getPermission denied (publickey), stop and fix SSH — rsync cannot help. The teardown step at the end of this lab reverts this change; do not skip it. -
Confirm the remote shell is silent — this is the check that prevents the most confusing rsync failure there is:
ssh localhost 'true' | wc -cExpect:
0. Any other number means your shell startup prints something and will corrupt rsync's stream. -
Push a tree:
cd "$(mktemp -d)" && mkdir -p src && printf 'hello\n' > src/a.txt rsync -avih src/ localhost:"$PWD/dst/"Expect:
>f+++++++++ a.txtand a summary. Note the quoting:"$PWD/dst/"is expanded by your shell before rsync sees it, which is what you want when both sides are the same machine. -
Pull it back into a new directory and confirm the direction indicator flips:
rsync -avih localhost:"$PWD/dst/" back/Expect:
receiving incremental file listrather thansending. -
Break it deliberately, so the error is familiar when it is not deliberate:
rsync -av --rsync-path=/nonexistent/rsync src/ localhost:"$PWD/dst/"; echo "exit=$?"Expect:
rsync: connection unexpectedly closed (0 bytes received so far) [sender]and an error naming code 127 (remote command not found) or 126 (remote command could not be run), depending on how the remote shell reports the missing binary. -
Watch the connection get reused. Add the
ControlMasterblock from above to~/.ssh/configforHost localhost, then time two runs:time rsync -a src/ localhost:"$PWD/dst/" time rsync -a src/ localhost:"$PWD/dst/"Expect: the second run noticeably faster, because SSH reuses the multiplexed connection instead of doing a fresh handshake. The saving is small here and large on a real network.
-
Teardown — not optional. Steps 1, 2, and 7 made three persistent changes to your real machine, and each one widens its attack surface until reverted:
# 1. close the multiplexed connection and remove the ControlMaster block ssh -O exit localhost 2>/dev/null # then delete the "Host localhost" block you added to ~/.ssh/config # 2. remove the key you authorized (skip if the line predates this lab) grep -v -f ~/.ssh/id_ed25519.pub ~/.ssh/authorized_keys > /tmp/ak && mv /tmp/ak ~/.ssh/authorized_keys # 3. turn the SSH listener back off # macOS: System Settings → General → Sharing → Remote Login OFF # WSL/Ubuntu: sudo service ssh stopExpect:
ssh -o BatchMode=yes -o ConnectTimeout=2 localhost truenow fails. If you use SSH to this machine for other things, keep what you need — but decide that deliberately rather than by leaving the lab's residue in place.
Checkpoint: explain why rsync -av src/ host:'~/dst/' and rsync -av src/ host:dst/ reach the same place, and what would happen with host:/dst/.
Sources: rsync(1) — USAGE, CONNECTING TO AN RSYNC SERVER, --rsh, --rsync-path, --secluded-args · ssh_config(5) — ControlMaster, ProxyJump
11 · The rsync daemon
The daemon is rsync's other transport: a long-running server on TCP 873 that publishes named modules instead of filesystem paths. You reach it with a double colon or an rsync:// URL. It exists for cases SSH handles badly — anonymous public mirrors, high-volume transfers where SSH encryption is the bottleneck, appliances with no shell — and it is worth knowing precisely because those cases are narrow.
Two syntaxes, one protocol
# both of these mean "module pub on that host"
rsync rsync://localhost:8873/pub/
rsync --port=8873 localhost::pub/
The double colon is the tell. host:path is SSH; host::module is the daemon. Confusing the two produces a bewildering error, so read your own commands for that second colon.
A working configuration
This is a real, verified rsyncd.conf — running on a high port so it needs no privileges:
port = 8873
pid file = /path/to/lab/rsyncd.pid
log file = /path/to/lab/rsyncd.log
lock file = /path/to/lab/rsyncd.lock
use chroot = no
max connections = 4
[pub]
path = /path/to/lab/share
comment = read-only public mirror
read only = yes
list = yes
[drop]
path = /path/to/lab/upload
comment = authenticated write target
read only = no
auth users = labuser
secrets file = /path/to/lab/rsyncd.secrets
The secrets file is user:password, one pair per line. rsyncd.conf(5) requires that it "normally not be readable by other" — chmod 600 satisfies that, and a rejected file means no logins are possible for the module. There is no default path; you must name one. Start the daemon:
rsync --daemon --config=./rsyncd.conf --no-detach
@ERROR: failed to open lock file then rsync error: error starting client-server protocol (code 5). Cause: max connections requires a lock file, and the default path is /var/run/rsyncd.lock, which an unprivileged daemon cannot write. Fix: set lock file explicitly, as above. The daemon starts and lists modules fine without it — the failure only appears when a client actually opens a module, which makes it feel unrelated.
Using it
# what modules exist?
rsync rsync://localhost:8873/
pub read-only public mirror
drop authenticated write target
# what is in one?
rsync rsync://localhost:8873/pub/
drwxr-xr-x 128 2026/08/06 12:44:54 .
-rw-r--r-- 5 2026/08/06 12:44:54 data.csv
-rw-r--r-- 12 2026/08/06 12:44:54 readme.txt
# pull it
rsync -avi rsync://localhost:8873/pub/ ./pull/
receiving incremental file list
.d..t...... ./
>f+++++++++ data.csv
>f+++++++++ readme.txt
A bare module list is something SSH transport cannot do — there is no "what may I access?" query over SSH. That discoverability is the daemon's genuine advantage.
Authentication and refusals
# writing to a read-only module
rsync -a share/ rsync://localhost:8873/pub/
ERROR: module is read only
rsync error: syntax or usage error (code 1) at main.c(1168) [Receiver=3.4.4]
# writing to an auth module with no credentials
rsync -a share/ rsync://localhost:8873/drop/
Password: @ERROR: auth failed on module drop
rsync error: error starting client-server protocol (code 5) at main.c(1874) [sender=3.4.4]
# with credentials
RSYNC_PASSWORD=labpass123 rsync -avi share/ rsync://labuser@localhost:8873/drop/
sending incremental file list
<f+++++++++ data.csv
<f+++++++++ readme.txt
Note the itemize direction flipped to < — bytes going out to the remote rather than arriving. Same decoder (§7), position 1.
- The protocol is unencrypted and unauthenticated by default. A module without
auth usersis readable by anyone who can reach the port. Thesecrets filemechanism sends a challenge-response, not a password in clear — but the file contents are still plaintext on disk, so treat those credentials as low-value and never reuse a real password. use chroot = nois convenient and materially weaker. The 3.4.3 release fixed multiple daemon-mode vulnerabilities that apply specifically to non-chroot operation, including CVE-2026-29518 (symlink race enabling local privilege escalation) and CVE-2026-43619. The lab config above usesnoonly because it must run unprivileged. A real daemon runs chrooted, as a dedicated unprivileged user, withhosts allowset.- Patch level matters more here than anywhere else in rsync. 3.4.0 fixed six CVEs including a heap overflow reachable from a malicious server (CVE-2024-12084); 3.4.3 fixed six more. If you expose a daemon to a network you do not control, run a current build — Ubuntu 24.04 LTS's 3.2.7 carries backported fixes, which is fine, but an unpatched 3.2.x is not.
- Prefer SSH. For anything involving your own machines, SSH transport gives you encryption, real authentication, and no new listening port. Reach for the daemon when you need anonymous read access or when you have measured SSH's encryption as your bottleneck — not by default.
The middle path: daemon over SSH
You can get module semantics with SSH's security by having rsync launch a single-use daemon over an SSH connection, so nothing listens on a port:
rsync -av --rsh=ssh host::module/ ./local/
Three details the manual is emphatic about, and each of them will bite you:
--rshmust be on the command line. SettingRSYNC_RSHin the environment does not enable this mode.- The config comes from the remote user's home directory, not
/etc/rsyncd.conf. The daemon is spawned fresh by that user. user@host::moduledoes not mean what you think. Hereuser@is the rsync module username, not the SSH username. To set the SSH user you pass it to ssh:--rsh='ssh -l deploy'.
Because the daemon is started by an ordinary user, chroot and uid-changing are unavailable — you trade those for encryption and SSH authentication. This is still how you give someone access to exactly one directory without giving them a usable shell.
-
Build the lab and its config:
cd "$(mktemp -d)" && mkdir -p share upload printf 'public file\n' > share/readme.txt ; printf 'more\n' > share/data.csv cat > rsyncd.conf <<EOF port = 8873 pid file = $PWD/rsyncd.pid log file = $PWD/rsyncd.log lock file = $PWD/rsyncd.lock use chroot = no max connections = 4 [pub] path = $PWD/share comment = read-only public mirror read only = yes list = yes [drop] path = $PWD/upload comment = authenticated write target read only = no auth users = labuser secrets file = $PWD/rsyncd.secrets EOF printf 'labuser:labpass123\n' > rsyncd.secrets && chmod 600 rsyncd.secrets -
Start it in a second terminal (or background it) and leave it running:
rsync --daemon --config="$PWD/rsyncd.conf" --no-detachExpect: no output. If it exits with
failed to lock pid file … Resource temporarily unavailable, an earlier daemon is still alive —pkill -f 'rsync --daemon'and retry. -
Discover what it offers:
rsync rsync://localhost:8873/Expect: two lines,
pubanddrop, with their comments. -
List and then pull a module:
rsync rsync://localhost:8873/pub/ rsync -avi rsync://localhost:8873/pub/ ./pull/Expect: a directory listing, then
>f+++++++++ data.csvand>f+++++++++ readme.txt. -
Try to write where you may not:
rsync -a share/ rsync://localhost:8873/pub/Expect:
ERROR: module is read onlyand exit code 1. -
Authenticate and write:
RSYNC_PASSWORD=labpass123 rsync -avi share/ rsync://labuser@localhost:8873/drop/ ls upload/Expect:
<f+++++++++ data.csvand<f+++++++++ readme.txt, then both files present inupload/. -
Read the log, then shut it down:
tail rsyncd.log pkill -f 'rsync --daemon'Expect: connection and module-access lines. Confirm the daemon is gone with
pgrep -fl 'rsync --daemon'printing nothing.
Checkpoint: state the difference between host:/srv/data/ and host::data/, and which one needs a shell account on the far side.
Sources: rsyncd.conf(5) · rsync NEWS — 3.4.0 and 3.4.3 security releases
12 · Metadata, symlinks, and crossing platforms
-a preserves seven things and omits five — and one of the omissions means that if you back up a Mac home directory without -X, you are silently losing Finder tags, download provenance, and every other scrap of metadata that lives in extended attributes. No error will tell you. This section is the complete model that §5 deliberately simplified: what each omission costs, on which filesystem, and which flag buys it back.
What -a leaves behind
| Flag | Preserves | Matters when |
|---|---|---|
-H / --hard-links | hard-link relationships between files in the transfer | Backing up trees that already contain hard links — including previous rsync snapshot trees. Costs memory proportional to file count. |
-A / --acls | POSIX / macOS access control lists | Shared servers, macOS files with Finder-set ACLs. Implies -p. |
-X / --xattrs | extended attributes | macOS: Finder tags, Spotlight comments, quarantine flags, resource forks. Linux: SELinux labels, user.* attributes. |
-U / --atimes | access times | Almost never. Reading a file changes its atime, so this rarely stays true. |
-N / --crtimes | creation ("birth") times | macOS archival where Finder's "Date Created" must survive. Requires OS and filesystem support. |
-S / --sparse | holes in sparse files | VM disk images, database files, anything with large zero runs. |
Extended attributes on macOS: verified
This is not theoretical. A file with Finder metadata, synced with plain -a:
xattr -l src/f.txt
com.apple.metadata:kTestKey: hello
com.apple.provenance:
user.custom: v1
rsync -a src/ dst/ && xattr -l dst/f.txt
com.apple.provenance: ← everything else silently dropped
rsync -aX src/ dst2/ && xattr -l dst2/f.txt
com.apple.metadata:kTestKey: hello
com.apple.provenance:
user.custom: v1 ← preserved
-X, you are losing data
Finder tags, Spotlight comments, the "where from" URL on downloads, and legacy resource forks all live in extended attributes. The files still open; the metadata is gone, silently, with no error. For Mac-to-Mac backups the reflex is -aX, and add -N if creation dates matter to you.
ACLs on macOS: a real trap with a real workaround
A file carrying an ACL that denies deletion breaks rsync's normal write path, because rsync writes to a temporary file and then renames it over the target — and the ACL forbids exactly that. Reproduced on macOS 15.6 (the chmod +a syntax is macOS-only; on Linux the equivalent experiment uses setfacl, though POSIX ACLs cannot express a deny-delete rule, so this specific trap is a Mac one):
chmod +a "everyone deny delete" src/f.txt
rsync -aA src/ dst/
rsync: [receiver] rename "…/dst/.f.txt.x9NXJX" -> "f.txt": Permission denied (13)
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1356) [sender=3.4.4]
Plain -a on the same file succeeds — because without -A the ACL is never applied to the temp file, so the rename is permitted. And the fix that keeps the ACL:
rsync -aA --inplace src/ dst/ && ls -le dst/f.txt
-rw-r--r--@ 1 you staff 12 Aug 6 12:46 f.txt
0: group:everyone deny delete
--inplace writes directly to the destination file instead of writing-then-renaming, sidestepping the ACL entirely. Understand the trade before you adopt it: §13 explains what --inplace costs you in crash safety.
Symlinks: five behaviors, pick deliberately
| Flag | Behavior |
|---|---|
-l (in -a) | Copy symlinks as symlinks. The default and usually right. |
-L / --copy-links | Follow every symlink and copy what it points at, as a regular file. |
-k / --copy-dirlinks | Follow symlinks that point at directories only. |
--safe-links | Silently drop symlinks pointing outside the transfer tree. |
--munge-links | Mangle link targets so they cannot be followed on the receiver. For untrusted sources. |
# -a: symlinks stay symlinks, including ones that escape the tree
cL+++++++++ inside.lnk -> real.txt
cL+++++++++ outside.lnk -> /etc/hosts
cL+++++++++ sub/escape.lnk -> ../../../etc/passwd
# --safe-links: the escaping links are dropped without comment
cL+++++++++ inside.lnk -> real.txt ← only the safe one survives
# -L: links are replaced by their contents; a broken link is an error
symlink has no referent: "…/sub/escape.lnk"
>f+++++++++ inside.lnk
>f+++++++++ outside.lnk
rsync error: some files/attrs were not transferred (code 23)
config pointing at /etc/shadow is a legal thing for a source tree to contain, and -L will happily dereference it. When receiving from anywhere you do not control, use --safe-links, and consider --munge-links. Do not use -L on untrusted input. CVE-2024-12087 and CVE-2024-12088 in the 3.4.0 release were exactly this class of problem — another reason to be current.
Hard links: -H costs something and buys something
# src contains real.txt and hard.txt sharing one inode
rsync -a src/ out4/ # without -H
rsync -aH src/ out5/ # with -H
without -H: 1 links / with -H: 2 links
du -sh out4 out5
8.0K out4
4.0K out5
Without -H, hard-linked files become independent copies — the tree still works, it just doubles in size. rsync must hold the full inode map in memory to detect links, so -H on a multi-million-file tree is a memory conversation. For snapshot backups (§14) it becomes essential, because those trees are made of hard links.
Sparse files
# a 64 MB file that occupies zero blocks
src apparent=67108864 blocks=0
rsync -a src/ dst/ → no -S: blocks=131072 ← fully materialized
rsync -aS src/ dst2/ → with -S: blocks=0 ← holes preserved
Without -S, a sparse 64 MB file becomes a real 64 MB file. With VM images this turns a 20 GB backup into 500 GB. Combining -S with --inplace is accepted (since 3.1.3) but may silently produce non-sparse files, depending on kernel and filesystem — verify with ls -ls if the combination matters to you.
Permissions: the surprise about the destination directory itself
src/ only touches what's inside dst."
The trailing slash sends the contents — and the source directory's own attributes, applied to the destination directory. Verified:
before: src=755 dst=700
rsync -a src/ dst/
after: src=755 dst=755 ← dst's own mode was rewritten
If the destination directory's permissions are load-bearing, use --no-perms, or --omit-dir-times if only timestamps are the problem, or set the mode explicitly with --chmod=D755,F644.
--chmod is the tool for imposing permissions rather than copying them. D prefixes apply to directories, F to files:
rsync -a --chmod=D755,F644 src/ dst/ # source file was 777
stat -f '%N %Lp' dst/f.txt
dst/f.txt 644
Ownership: the promise -a cannot keep
-a preserves ownership."
It includes -o and -g, but owner-setting works only when the receiving rsync runs with privilege (root, or CAP_CHOWN). Without it, rsync does not error — it silently writes every file as the invoking user, and the failure surfaces months later, at restore time, when nothing belongs to who it should. Verified — the request is simply ignored:
rsync -a --chown=root:wheel src/ dst/ ; echo "exit=$?"
exit=0
ls -l dst/f.txt
-rw-r--r-- 1 you staff 3 Aug 7 10:30 f.txt ← not root:wheel; no warning either
Four flags govern what happens to ownership when the two sides disagree about users:
| Flag | Does | Reach for it when |
|---|---|---|
--chown=USER:GROUP | force one owner/group on everything received (a shorthand for --usermap/--groupmap with a * pattern) | Deploys: push as you, land as www-data. Needs privilege on the receiving side, like -o itself. |
--numeric-ids | skip name-matching; transfer raw UID/GID numbers | Full-system backup and restore. Name-matching quietly remaps files when the two machines assign the same username different IDs — numeric is what you want when the destination will one day become the source again. |
--usermap=FROM:TO, --groupmap | rewrite specific owners in flight (names or IDs, * wildcards) | Migrating between machines whose accounts differ by more than one name. |
--fake-super | instead of chowning, record owner, group, and device info in private xattrs on the receiving side, and read them back on the return trip | Backing up as a non-root user to a destination that supports xattrs. The backup stays restorable with correct ownership even though no privilege was ever involved. For a remote receiver, enable it on that side only with -M--fake-super (-M passes any option through to the remote rsync alone). |
Cross-platform reality
| Route | Use | Because |
|---|---|---|
| macOS → macOS | -aXN (add -A only if you need ACLs) | Finder metadata lives in xattrs; creation dates matter to Finder. |
| macOS → Linux | -a, plus --iconv=utf-8-mac,utf-8 if filenames use accents | Linux will not use Apple's xattrs. Filename normalization can differ. |
| Linux → Linux | -aHAX | Everything is supported; preserve it all. |
Any → NTFS via WSL /mnt/c | -rlt --no-perms --no-owner --no-group --modify-window=1 | NTFS has no Unix mode bits; timestamps can be coarse. |
| Any → exFAT / FAT external drive | -rlt --modify-window=1 | No ownership, no permissions, 2-second timestamp granularity. |
| Any → NAS mounted over SMB | -rlt --no-perms --no-owner --no-group --modify-window=1 | The mount cannot represent Unix ownership or xattrs faithfully; see below. |
/Volumes/backup is a local path to rsync, with a foreign filesystem behind it. Three consequences. First, metadata flags lie: -A and -X either fail outright or store approximations the SMB layer invents, and ownership belongs to the mount, not to you — hence the --no-perms --no-owner --no-group row above, with --modify-window=1 because SMB servers often round timestamps. Second, because the path is local, rsync defaults to --whole-file (§6); forcing --no-whole-file makes rsync read the old copy back over the network to compute deltas, which is usually slower than just sending the file — measure before you assume delta transfer helps here. Third, the clean escape exists: if the NAS runs an SSH server, sync to nas:/volume1/backup/ instead of /Volumes/backup/. Then rsync runs natively on the NAS, delta transfer works as designed, ownership is applied by the far side, and --fake-super can record what an unprivileged NAS account cannot set.
café.txt written on a Mac uses decomposed Unicode (e + combining accent) while Linux keeps whatever bytes it was given, producing apparent duplicates. rsync's --iconv=utf-8-mac,utf-8 translates between the two. Tested on APFS for this guide, both encodings collapsed to a single file — the volume treats them as the same name, so the duplicate could not even be created locally. The problem is real when syncing to a Linux filesystem that keeps them distinct; it did not reproduce within macOS. Reach for --iconv if you see doubled filenames after a macOS→Linux sync, not preemptively.
Two path tools worth knowing
-R / --relative preserves the full source path inside the destination, and a /./ pivot chooses where the preserved portion starts:
rsync -aR a/b/c/f.txt dst/
dst/a/b/c/f.txt ← whole path recreated
rsync -aR a/./b/c/f.txt dst2/
dst2/b/c/f.txt ← path preserved from the ./ onward
--files-from=FILE takes an explicit list, one path per line, relative to the source root. It implies -R and -d, and -a stops implying -r — add -r back explicitly if a listed directory's contents should come along. Paths land exactly where the list says:
cat list.txt
x/one.txt
y/two.txt
rsync -a --files-from=list.txt src/ dst/ && find dst -type f
dst/x/one.txt
dst/y/two.txt
This is how you drive rsync from the output of find, git ls-files, or a manifest. Add --from0 when the list is NUL-separated, which is the safe way to handle filenames with newlines.
Creating the destination path: --mkpath
rsync creates the final destination directory but not its missing parents. Verified:
rsync -a src/ deep/nested/dst/
rsync: [Receiver] mkdir "…/deep/nested/dst" failed: No such file or directory (2)
rsync error: error in file IO (code 11) at main.c(800) [Receiver=3.4.4]
rsync -a --mkpath src/ deep/nested/dst/
find deep -maxdepth 3 -type d
deep
deep/nested
deep/nested/dst
Before --mkpath existed (added in 3.2.3) the idiom was mkdir -p first, which still works and is more portable if either side might be older. Note that --mkpath and --dry-run together had a bug for file-to-file copies that 3.4.4 fixed — one more reason the version you run matters.
-a is dropping
10 minutes · local only · macOS-focused, Linux notes inline
-
Create a file with metadata attached:
cd "$(mktemp -d)" && mkdir -p src dst dst2 printf 'tagged\n' > src/f.txt xattr -w user.custom 'v1' src/f.txt # Linux: setfattr -n user.custom -v v1 xattr -l src/f.txtExpect: at least
user.custom: v1. macOS may also showcom.apple.provenance. -
Sync with plain
-aand inspect:rsync -a src/ dst/ && xattr -l dst/f.txtExpect:
user.customis gone. No warning was printed. -
Now with
-X:rsync -aX src/ dst2/ && xattr -l dst2/f.txtExpect:
user.custom: v1present. -
Watch the destination directory's own mode get rewritten:
mkdir -p pm/src pm/dst && chmod 755 pm/src && chmod 700 pm/dst printf 'x\n' > pm/src/f.txt stat -f '%Lp' pm/dst # Linux: stat -c '%a' pm/dst rsync -a pm/src/ pm/dst/ stat -f '%Lp' pm/dstExpect:
700then755. Re-run with--no-permson a fresh pair and confirm700survives. -
Test symlink safety:
mkdir -p ln/src && printf 'target\n' > ln/src/real.txt ln -s real.txt ln/src/inside.lnk ln -s /etc/hosts ln/src/outside.lnk rsync -ai ln/src/ ln/out/ rsync -ai --safe-links ln/src/ ln/out2/Expect: the first run lists both
cL+++++++++links; the second lists onlyinside.lnk, dropping the escaping one with no message. -
Make a sparse file and prove
-Smatters:mkdir -p sr/src && dd if=/dev/zero of=sr/src/img bs=1m count=0 seek=64 status=none # Linux: bs=1M rsync -a sr/src/ sr/a/ && stat -f '%b' sr/a/img # Linux: stat -c '%b' rsync -aS sr/src/ sr/b/ && stat -f '%b' sr/b/imgExpect: a large block count (around
131072) without-S, and0with it.
Checkpoint: write the flag set you would use to back up your own home directory to an external drive, and justify each letter beyond -a.
13 · Speed, interruption, and resuming
rsync is fast by default and there are exactly four levers worth pulling. Pulling them without measuring usually makes things slower, so each one below comes with the condition under which it helps.
Lever 1: compression — narrower than you think
# 5 MB of highly compressible data, over a remote transport
rsync -a --stats -z --zc=zstd src/ host:dst/
Total bytes sent: 366
speedup is 12,468.83
rsync -a --stats src/ host:dst2/
Total bytes sent: 5,001,339
speedup is 1.00
That is a spectacular ratio on synthetic data and a misleading one. Real guidance:
- Use
-zwhen the link is slower than the CPU. Home upload links, cellular, VPNs. On a LAN or a local disk, compression is a net loss. - Choose the algorithm. rsync 3.2+ supports
zstd,lz4,zlibx,zlib.--zc=zstdis the best general choice;--zc=lz4when CPU is the constraint. Check availability withrsync --version | grep -A1 'Compress list'. If the far side is older, the negotiation falls back. - Skip already-compressed files.
--skip-compress=gz/jpg/mp4/zst/xz/png/webmavoids burning CPU on data that will not shrink. A sensible default list is compiled in; override when your data is unusual.
Lever 2: --partial — the difference is stark
Interrupt a large transfer with Ctrl+C and look at the destination. Without --partial:
rsync error: received SIGINT, SIGTERM, or SIGHUP (code 20) at rsync.c(716) [sender=3.4.4]
ls -la dst/
total 0 ← the partial file was discarded
With --partial:
ls -la dst/
-rw-r--r-- 1 you staff 6094848 Dec 31 1969 huge.bin
Six megabytes survived, and note the timestamp: 1969. rsync deliberately leaves the partial file with an ancient mtime so the quick check cannot mistake it for complete. On the next run it becomes the delta basis and only the remainder transfers.
--partial-dir, not bare --partial
Bare --partial leaves the incomplete file at its final name. If your interrupted transfer is a live website, you have just published a truncated file. --partial-dir=.rsync-partial keeps fragments in a hidden subdirectory instead:
rsync -avh --partial-dir=.rsync-partial --info=progress2 big/ host:/srv/big/
For a relative --partial-dir value like this one, rsync appends a "perishable" exclude for the fragment directory itself, so --delete will not normally clean it up between attempts. Add your own --exclude='.rsync-partial' only if an earlier filter rule of yours would override that automatic one.
Lever 3: --inplace — a real trade, not a free win
What it buys
- No temporary file, so no need for free space equal to the file size.
- Preserves hard links to the destination file and its ACLs (§12).
- Faster for huge files on nearly-full volumes.
What it costs
- No atomicity. An interruption leaves a file that is neither old nor new.
- Readers see a partially updated file mid-transfer.
- With
--sparse, may silently produce non-sparse files (accepted since 3.1.3, but hole-punching depends on kernel and filesystem). Breaks--link-destsnapshot sharing by modifying the shared inode.
Use it for a VM image on a full disk. Do not use it for a document tree, a website, or anything feeding a snapshot backup.
Lever 4: fewer round trips
- SSH connection reuse.
ControlMasterin~/.ssh/config(§10) removes a full handshake per invocation. For a script that runs rsync in a loop, this is often the largest single win. - One rsync, not many. Ten thousand small files in one invocation beats a shell loop calling rsync per file by orders of magnitude — the file list is streamed and the connection is shared.
--bwlimitis a throttle, not a tuner. It exists to leave bandwidth for other work, not to go faster (units in §10).--stop-after=MINS/--stop-at=y-m-dTh:m. Bound a transfer to a maintenance window; combine with--partial-dirso the next window resumes. Confirm support withrsync --version | grep stop-at.
Reading --info=progress2 output — including why to-chk grows early in a big transfer — is covered in §7; nothing about it changes at scale.
tar piped over ssh) for the initial seed, then using rsync for incremental updates where its per-file cost is amortized across far fewer changes.
Sources: rsync(1) — --compress, --compress-choice, --partial-dir, --inplace, --bwlimit, --stop-after
14 · Snapshot backups with --link-dest
This is rsync's best trick and the reason many people never buy backup software. With one extra flag you get a directory per backup — each one a complete, browsable, restorable copy of the whole tree — while unchanged files cost zero additional bytes. Time Machine's core idea, in a command you can read.
The mechanism: hard links
A hard link is a second name for the same inode. Two names, one set of blocks on disk, one reference count. Delete one name and the data survives while the other name exists. rsync's --link-dest=DIR says: when a file is unchanged from the copy in DIR, do not transfer it — hard-link to it instead.
cd into, diff, and restore from with an ordinary cp. Unchanged files across snapshots are the same inode, so a hundred daily snapshots of a mostly-static tree cost barely more than one.Proof from a real run
# snapshot 1: full copy of a tree containing a 1 MB blob
rsync -a --delete src/ backups/2026-08-06_1200/
# edit one small file, then snapshot 2 linked against snapshot 1
rsync -a --delete --link-dest=../2026-08-06_1200 src/ backups/2026-08-06_1300/
du -sh backups/*
1.0M backups/2026-08-06_1200
4.0K backups/2026-08-06_1300
du -sh backups
1.0M backups ← two full snapshots, one snapshot of space
stat -f '%N links=%l inode=%i' backups/*/stable.txt backups/*/report.txt
backups/2026-08-06_1200/stable.txt links=2 inode=445333596
backups/2026-08-06_1300/stable.txt links=2 inode=445333596 ← same inode
backups/2026-08-06_1200/report.txt links=1 inode=445333595
backups/2026-08-06_1300/report.txt links=1 inode=445333602 ← different: it changed
--link-dest paths carefully
The path is interpreted relative to the destination directory, not your working directory. That is why the example uses ../2026-08-06_1200 while the destination is backups/2026-08-06_1300/. Getting this wrong fails silently — rsync finds no basis, links nothing, and quietly writes a full copy. If a snapshot is unexpectedly large, this is the first thing to check. Absolute paths sidestep the ambiguity entirely and are worth the verbosity in a script.
--link-dest has two siblings and a cousin
| Flag | When a file is unchanged vs the basis dir | Reach for it when |
|---|---|---|
--link-dest=DIR | hard-link to the copy in DIR | Snapshot rotation — full trees, one snapshot of space. This section. |
--copy-dest=DIR | copy it locally from DIR instead of transferring | You want the network savings but independent files — a destination filesystem where hard links are unwanted or unsupported. |
--compare-dest=DIR | skip it entirely; write nothing | Building a delta directory: only what changed relative to a baseline lands in the destination. |
-y / --fuzzy | (no basis dir) — look for a similar-named file already in the destination to use as a delta basis | Renamed files over a slow link, where a near-identical neighbour saves most of the transfer. |
A complete, working snapshot script
This was run end to end against rsync 3.4.4 on macOS while writing this guide — including the mount guard, the link basis, and retention pruning. It uses no GNU-only tools, so it runs unchanged on Ubuntu/WSL.
#!/usr/bin/env bash
# snapshot-backup.sh — dated hardlinked snapshots with retention.
set -euo pipefail
SRC="${1:?usage: $0 SRC DEST_ROOT}"
DEST_ROOT="${2:?usage: $0 SRC DEST_ROOT}"
KEEP="${KEEP:-30}"
EXCLUDES="${EXCLUDES:-$HOME/.config/rsync/backup-excludes}"
STAMP="$(date +%Y-%m-%dT%H%M%S)"
TARGET="$DEST_ROOT/$STAMP"
# Refuse to run against an unmounted volume — see §9.
[ -e "$DEST_ROOT/.backup-marker" ] || { echo "destination not mounted: $DEST_ROOT" >&2; exit 1; }
# Newest existing snapshot becomes the link basis, if there is one.
# Relative to "$TARGET.incomplete/", so "../NAME" resolves inside DEST_ROOT.
link=()
latest="$(ls -1 "$DEST_ROOT" 2>/dev/null | grep -E '^[0-9]{4}-' | sort | tail -1 || true)"
[ -n "$latest" ] && link=(--link-dest="../$latest")
opts=(-aHAX --delete --delete-excluded --max-delete=500 --partial-dir=.rsync-partial)
[ -r "$EXCLUDES" ] && opts+=(--exclude-from="$EXCLUDES")
rsync "${opts[@]}" "${link[@]}" "$SRC/" "$TARGET.incomplete/"
# A dated directory exists only once the snapshot is complete.
mv "$TARGET.incomplete" "$TARGET"
# Retention: keep the newest $KEEP dated directories, delete the rest.
ls -1 "$DEST_ROOT" | grep -E '^[0-9]{4}-' | sort -r | tail -n +"$((KEEP + 1))" |
while IFS= read -r old; do
echo "pruning $old"
rm -rf "${DEST_ROOT:?}/${old:?}"
done
Verified behavior, run five times with KEEP=2:
pruning 2026-08-06T130655
pruning 2026-08-06T130656
pruning 2026-08-06T130703
ls dest
2026-08-06T130704
2026-08-06T130706
--link-dest="../$latest"is relative to"$TARGET.incomplete/", which sits directly insideDEST_ROOT— so../lands on the sibling snapshots. Change the layout and you must recheck this path, because getting it wrong fails silently into full copies.mv "$TARGET.incomplete" "$TARGET"makes completion atomic. A crashed run leaves an.incompletedirectory, which the^[0-9]{4}-filter ignores for both linking and retention.--delete-excludedis correct here: a backup should not accumulate files you have since decided to stop backing up.sort -r | tail -n +$((KEEP+1))rather thanhead -n -$KEEP, because negativeheadcounts are a GNU extension and macOS'sheadrejects them. This is the kind of detail that makes a script work on one of your two machines.
-N to opts if creation dates matter. Do not add it on Linux, where the filesystem may not support setting birth times.
A starting backup-excludes file
The script reads ~/.config/rsync/backup-excludes. Here is a defensible starting set for a home directory on either platform — every entry is regenerated data that inflates snapshots without making them more restorable. Lines starting with # are comments; unanchored patterns match at any depth (§8):
# caches — rebuilt on demand, most of a home directory's churn
.cache/
Library/Caches/
node_modules/
.npm/
__pycache__/
.venv/
# trash and per-volume bookkeeping — recreated automatically
.Trash/
.DS_Store
.Spotlight-V100/
.fseventsd/
.TemporaryItems/
Two judgment calls to make deliberately rather than by omission: VM and container disk images (huge, change entirely every run — consider excluding them here and backing them up on their own schedule), and ~/Library beyond Caches on macOS (keep it — application state lives there, and losing it is what makes a restore feel incomplete).
Restoring
# one file, from a specific day
cp backups/2026-08-04T030000/notes.md ~/notes.md
# the whole tree, back to how it was — note --delete makes this exact
rsync -aHAXN --delete backups/2026-08-04T030000/ ~/restored/
# what changed between two snapshots?
rsync -ain --delete backups/2026-08-05T030000/ backups/2026-08-06T030000/
That last command is the quiet superpower: a dry run between two snapshots prints an itemized diff of your entire backup history, with no special tooling.
Limits you should know before trusting it
- Hard links need one filesystem. Snapshots must live on the same volume as each other. You cannot link across a mount point.
- Editing a file in a snapshot corrupts every snapshot. They are the same inode. Treat snapshot trees as read-only; do not use
--inplaceanywhere near them. - Deleting a snapshot is safe and only frees blocks whose last reference went away.
rm -rfon an old snapshot is the correct retention mechanism. - Link counts have limits and
rm -rfof a large snapshot is metadata-heavy. Hundreds of snapshots of millions of files will feel it. - This is not off-site. A hardlinked snapshot tree on the same disk survives your mistakes, not the disk's death or a ransomware event that can write to the volume. Pair it with a copy somewhere you cannot casually delete from.
- openrsync supports
--link-dest— verified to produce real shared inodes — so this technique still works if you are stuck with Apple's build. You lose-X, so Finder metadata will not be in the backup.
-
Create a source with one big file and one small one:
cd "$(mktemp -d)" && mkdir -p src backups printf 'v1 of report\n' > src/report.txt printf 'stable\n' > src/stable.txt head -c 1048576 /dev/zero | tr '\0' 'B' > src/blob.bin -
Take the first snapshot (no basis exists yet):
rsync -a --delete src/ backups/2026-08-06_1200/ du -sh backups/2026-08-06_1200Expect: about
1.0M. -
Edit one small file and take a linked snapshot:
printf 'v2 of report, edited\n' > src/report.txt rsync -a --delete --link-dest=../2026-08-06_1200 src/ backups/2026-08-06_1300/ du -sh backups/* ; du -sh backupsExpect:
1.0Mfor the first,4.0Kfor the second, and1.0Mfor the wholebackupstree. Two complete snapshots, one snapshot of disk. -
Prove the sharing is real, not an illusion of
du:stat -f '%N links=%l inode=%i' backups/*/stable.txt backups/*/report.txt # Linux: stat -c '%n links=%h inode=%i' backups/*/stable.txt backups/*/report.txtExpect: both
stable.txtentries showinglinks=2and the same inode number; the tworeport.txtentries showinglinks=1and different inodes. -
Confirm each snapshot is independently complete:
cat backups/2026-08-06_1200/report.txt cat backups/2026-08-06_1300/report.txtExpect:
v1 of reportandv2 of report, edited. Neither is a stub or a pointer; both are ordinary readable files. -
Diff your backup history with a dry run:
rsync -ain --delete backups/2026-08-06_1200/ backups/2026-08-06_1300/Expect: an itemized line for
report.txtonly — the one file that differs between the two snapshots. -
Delete the old snapshot and confirm the newer one is unharmed:
rm -rf backups/2026-08-06_1200 cat backups/2026-08-06_1300/stable.txt ; du -sh backupsExpect:
stablestill readable, andbackupsstill about1.0M— the blocks moved from "shared" to "owned by the survivor", they did not disappear.
Checkpoint: explain why step 7 did not lose data, using the words "inode" and "reference count".
Sources: rsync(1) — --link-dest, --compare-dest, --copy-dest
15 · Scheduling: making it run without you
An interactive rsync command and a scheduled one are different programs with the same name. The scheduled one has no terminal, a minimal environment, no SSH agent, and nobody watching its output. Every difference below is a way that unattended rsync fails while the same command works when you type it.
Exit codes — the complete list
From rsync 3.4.4's manual. Your script needs this table because "non-zero means broken" is too coarse:
| Code | Meaning | In a backup job |
|---|---|---|
| 0 | Success | Done. |
| 1 | Syntax or usage error | Your script is wrong. Alert. |
| 2 | Protocol incompatibility | Version mismatch too wide to bridge. Alert. |
| 3 | Errors selecting input/output files, dirs | Usually a bad path. Alert. |
| 4 | Requested action not supported | A flag the remote does not know. Alert. |
| 5 | Error starting client-server protocol | Daemon auth or config. Alert. |
| 6 | Daemon unable to append to log-file | Daemon-side permissions. |
| 10 | Error in socket I/O | Network. Retry is reasonable. |
| 11 | Error in file I/O | Disk full, unwritable destination. Alert. |
| 12 | Error in rsync protocol data stream | Often a chatty remote shell (§10). |
| 13 | Errors with program diagnostics | Rare. |
| 14 | Error in IPC code | Rare. |
| 20 | Received SIGUSR1 or SIGINT | Interrupted. Retry. |
| 21 | Some error returned by waitpid() | Rare. |
| 22 | Error allocating core memory buffers | Out of memory — often -H on a huge tree. |
| 23 | Partial transfer due to error | Something was unreadable. Usually tolerable — see below. |
| 24 | Partial transfer due to vanished source files | Normal on a live system. Treat as success. |
| 25 | --max-delete limit stopped deletions | Your guard fired. Investigate immediately. |
| 30 | Timeout in data send/receive | Network. Retry. |
| 35 | Timeout waiting for daemon connection | Daemon unreachable. Retry. |
# The wrapper every scheduled rsync deserves
set +e
rsync "${opts[@]}" "$SRC/" "$DEST/"
rc=$?
set -e
case $rc in
0) echo "backup ok" ;;
24) echo "backup ok (some source files vanished mid-run)" ;;
23) echo "backup completed with unreadable files — check the log" ;;
25) echo "REFUSED: --max-delete tripped. Source may be missing." >&2; exit 25 ;;
*) echo "backup FAILED with code $rc" >&2; exit $rc ;;
esac
The four things that break unattended runs
1. PATH is not your PATH
cron and launchd give you a minimal PATH that does not include /usr/local/bin or /opt/homebrew/bin. On macOS this means your job silently runs openrsync instead of rsync 3.4.4, and flags start failing. Use absolute paths in scheduled jobs.
2. No SSH agent
Your interactive session has SSH_AUTH_SOCK; the scheduler does not. Use a passphrase-free key dedicated to the job, restricted on the far side.
3. No terminal
Drop --progress and -P; they write control sequences into your log. Use --stats and redirect to a file instead.
4. Overlapping runs
A backup that takes 70 minutes on an hourly schedule will eventually run against itself. Take a lock: flock on Linux, or a mkdir-based lock that works everywhere.
Restricting the key on the remote side
A key that can run any command is a key that can be stolen and used for anything. Restrict it to rsync in ~/.ssh/authorized_keys on the destination host:
command="rsync --server -logDtpre.iLsfxCIvu . /srv/backups/laptop/",restrict ssh-ed25519 AAAA… backup@laptop
The command= forces that exact invocation regardless of what the client asks for, and restrict disables port forwarding, agent forwarding, and PTY allocation. Get the exact --server string by running your real command once with -e 'ssh -v' and reading what rsync sends — the technique from §10. Be aware this pins your flag set: change the rsync options and you must update the forced command.
macOS: launchd
Save as ~/Library/LaunchAgents/com.you.rsync-backup.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.you.rsync-backup</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/bin/snapshot-backup.sh</string>
<string>/Users/you/Documents</string>
<string>/Volumes/Backup/documents</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key> <integer>3</integer>
<key>Minute</key> <integer>15</integer>
</dict>
<key>RunAtLoad</key> <false/>
<key>StandardOutPath</key> <string>/Users/you/Library/Logs/rsync-backup.log</string>
<key>StandardErrorPath</key> <string>/Users/you/Library/Logs/rsync-backup.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key> <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.rsync-backup.plist
launchctl kickstart -p gui/$(id -u)/com.you.rsync-backup # run it now, once
launchctl print gui/$(id -u)/com.you.rsync-backup | grep -E 'state|last exit'
launchctl bootout gui/$(id -u)/com.you.rsync-backup # unload
~/Documents, ~/Desktop, ~/Downloads, Photos, or Mail without Full Disk Access. What you see is rsync exiting 23 with Operation not permitted (1) on those paths — not a permission prompt, because there is no GUI session to prompt. Grant Full Disk Access to /bin/bash or /bin/zsh (whatever your script's interpreter is) in System Settings → Privacy & Security → Full Disk Access. Verify by running the job with launchctl kickstart and reading the log — not by running the script in Terminal, which has its own permissions and will succeed misleadingly.
Ubuntu / WSL: systemd timer
~/.config/systemd/user/rsync-backup.service:
[Unit]
Description=Hardlinked rsync snapshot backup
After=network-online.target
[Service]
Type=oneshot
ExecStart=/home/you/bin/snapshot-backup.sh /home/you/work /srv/backups/work
Nice=10
IOSchedulingClass=idle
# rsync exit 24 (vanished files) is not a failure
SuccessExitStatus=24
~/.config/systemd/user/rsync-backup.timer:
[Unit]
Description=Run rsync snapshot backup daily
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=600
[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now rsync-backup.timer
systemctl --user list-timers rsync-backup.timer
journalctl --user -u rsync-backup.service -n 50
Persistent=true makes the job run after boot if the scheduled time was missed — essential for a laptop. RandomizedDelaySec spreads load if several machines back up to the same target. Add sudo loginctl enable-linger $USER so user timers run when you are not logged in.
systemctl --version. If it errors, enable systemd by adding [boot] / systemd=true to /etc/wsl.conf and running wsl --shutdown from Windows. Failing that, fall back to cron — and remember that WSL only runs when a WSL process is alive, so a "daily 3 a.m." job in WSL fires only if the distro happens to be running. For genuinely unattended backups from a Windows machine, Task Scheduler invoking wsl.exe -d Ubuntu -- /home/you/bin/snapshot-backup.sh … is the reliable arrangement.
Plain cron, if you prefer it
crontab -e
# m h dom mon dow command
PATH=/usr/local/bin:/usr/bin:/bin
15 3 * * * /usr/bin/flock -n /tmp/rsync-backup.lock /home/you/bin/snapshot-backup.sh /home/you/work /srv/backups/work >> /home/you/logs/backup.log 2>&1
flock -n makes an overlapping run exit immediately rather than piling up. macOS has no flock command; use the portable lock instead:
# Portable mutual exclusion: mkdir is atomic everywhere
LOCK=/tmp/rsync-backup.lock
mkdir "$LOCK" 2>/dev/null || { echo "already running"; exit 0; }
trap 'rmdir "$LOCK"' EXIT
The lock's companion is --timeout=300 on the rsync command itself. Over a flaky link an unattended rsync can hang indefinitely on a dead connection — it will never produce the exit 30 in the table above unless you set a timeout, and a hung job holds the lock, so every later run exits "already running" until someone notices. Five minutes of I/O silence is a reasonable default for a nightly job; add --contimeout=60 for daemon connections, which covers the connection attempt itself.
Logging that is worth reading later
rsync -aHAX --delete --stats \
--log-file=/var/log/rsync-backup.log \
--log-file-format='%o %f %l %b' src/ dst/
%o is the operation (send/recv/del), %f the filename, %l the file length, %b the bytes actually transferred. Comparing %l against %b across a log is how you confirm delta transfer is doing its job in production.
Sources: rsync(1) — EXIT VALUES, --log-file-format · systemd.timer(5) · Apple — Creating launchd jobs
16 · Decision matrices
The wrong question is "which tool is best?" Every tool below is best at something. The right question is what property you actually need: exactness, continuity, versioning, or reach.
rsync versus everything else
| Tool | Model | Choose it when | Not when |
|---|---|---|---|
| rsync | One-shot reconciliation you invoke | You want a destination to match a source at a moment you choose, over SSH or locally, with exact control over what transfers. | You want continuous, bidirectional sync without thinking about direction. |
cp | Copy | Small, local, one-time. Genuinely faster for a handful of files. | Anything repeated, remote, or large. |
scp | Copy over SSH | Never, really. Modern scp is an sftp wrapper; rsync is better at the same job. | Anything you might have to resume or repeat. |
tar | ssh | Stream an archive | Initial seed of a million small files. Beats rsync when nothing exists at the destination yet. | Incremental updates — no delta, no skip. |
| Syncthing | Continuous peer-to-peer sync | Multiple devices you edit on, changes propagating both ways without you running anything. | You need a point-in-time snapshot or precise control over when transfers happen. |
| rclone | rsync-shaped, for object storage | The far side is S3, B2, Google Drive, or similar. rsync cannot speak those protocols. | Both sides are POSIX filesystems — rsync's delta algorithm has no rclone equivalent. |
| restic / borg | Deduplicating, encrypted backup repository | You need encryption at rest, deduplication across machines, or an untrusted backup destination. | You want a plain browsable directory tree you can restore with cp. |
| Time Machine | macOS system backup | Whole-Mac restores including system state. It knows about macOS in ways rsync does not. | Cross-platform, or scriptable, or restoring onto Linux. |
git | Content-addressed version control | Text you author and want history for. | Large binaries, or a tree you want to mirror rather than version. |
--link-dest snapshots to an external drive for point-in-time recovery, and restic to an off-site target for the case where the house burns down. Three tools, three different failure modes covered. rsync alone covers exactly one of them.
Three smaller decisions — comparison mode, deletion timing, compression — were each settled where they were taught: §6, §9, and §13. The cheat sheet (§19) compresses all three to one line each.
Sources: Syncthing documentation · rclone documentation · restic documentation
17 · Capstone: a real backup and deploy system
Everything in one exercise. Four phases, each depending on the last, all disposable. Budget 35–45 minutes. If a phase's checkpoint fails, the section it exercises is named — go back to it rather than pushing forward.
-
Build a project that looks like something you would actually own:
export LAB="$(mktemp -d)"; cd "$LAB" mkdir -p proj/src proj/assets proj/node_modules/dep proj/.git proj/dist printf 'int main(){}\n' > proj/src/main.c printf 'body{}\n' > proj/assets/site.css head -c 2097152 /dev/zero | tr '\0' 'P' > proj/assets/hero.png printf 'junk\n' > proj/node_modules/dep/index.js printf 'ref\n' > proj/.git/HEAD printf 'built\n' > proj/dist/app.js printf 'DEBUG\n' > proj/app.log mkdir -p backups deploy && touch backups/.backup-marker -
Write the exclude file and dry-run against it:
cat > excludes.txt <<'EOF' node_modules/ .git/ *.log EOF rsync -ain --exclude-from=excludes.txt proj/ backups/preview/Expect:
src/main.c,assets/site.css,assets/hero.png,dist/app.js. Nonode_modules, no.git, noapp.log.
Checkpoint 1: the dry run lists exactly four files and three directories. If app.log appears, your exclude file has a quoting or newline problem (§8).
-
Save the script from §14 as
./snap.sh, make it executable, and take the first snapshot:chmod +x snap.sh EXCLUDES="$LAB/excludes.txt" KEEP=3 ./snap.sh "$LAB/proj" "$LAB/backups" ls backups/Expect: one dated directory such as
2026-08-06T131500, plus.backup-marker— and nopreviewdirectory, because Phase 1's dry run created nothing. -
Change one small file and take a second snapshot:
sleep 1; printf 'body{color:red}\n' > proj/assets/site.css EXCLUDES="$LAB/excludes.txt" KEEP=3 ./snap.sh "$LAB/proj" "$LAB/backups" du -sh backups/2*/ ; du -sh backupsExpect: the first snapshot around 2 MB, the second only kilobytes, and the total close to the first alone. The 2 MB
hero.pngis shared, not copied. -
Prove sharing at the inode level:
stat -f '%N links=%l inode=%i' backups/2*/assets/hero.png # Linux: stat -c '%n links=%h inode=%i' backups/2*/assets/hero.pngExpect: two lines, same inode,
links=2. -
Exercise the mount guard — the failure mode that eats real backups:
mkdir -p notmounted ./snap.sh "$LAB/proj" "$LAB/notmounted"; echo "exit=$?"Expect:
destination not mounted: …/notmountedandexit=1. Nothing was written. -
Drive retention past its limit:
for i in 1 2 3; do sleep 1; printf "rev$i\n" > proj/src/main.c EXCLUDES="$LAB/excludes.txt" KEEP=3 ./snap.sh "$LAB/proj" "$LAB/backups"; done ls -1 backups/ | grep -c '^2'Expect:
pruning …lines during the runs, and exactly3dated directories left. -
Diff two points in your own backup history:
set -- backups/2*/ ; rsync -ain --delete "$1" "$3"Expect: an itemized line for
src/main.c— the only file that changed between the oldest and newest surviving snapshot.
Checkpoint 2: three snapshots exist, disk usage is roughly one snapshot's worth, and you can name the file that differs between any two of them without opening either.
-
Seed a "server" with content the deploy must never touch:
mkdir -p deploy/uploads printf 'user photo\n' > deploy/uploads/photo.jpg printf 'SECRET=1\n' > deploy/.env printf 'stale\n' > deploy/old-page.html -
Dry-run a naive deploy and read the damage:
rsync -ain --delete proj/dist/ deploy/Expect:
*deletinglines foruploads/photo.jpg,.env, andold-page.html. The naive command destroys production state. -
Add protect rules and dry-run again:
rsync -ain --delete-after \ --filter='P /uploads/' --filter='P /.env' proj/dist/ deploy/Expect:
*deleting old-page.htmlonly.uploads/and.envare no longer listed for deletion. -
Deploy for real, then verify:
rsync -avi --delete-after \ --filter='P /uploads/' --filter='P /.env' proj/dist/ deploy/ find deploy -type f | sortExpect:
deploy/.env,deploy/app.js,deploy/uploads/photo.jpg. Noold-page.html. -
Now do it over SSH, using localhost as the remote (setup in §10, Lab 7):
rsync -avih --delete-after \ --filter='P /uploads/' --filter='P /.env' proj/dist/ localhost:"$LAB/deploy/"Expect: a run with nothing to transfer — the destination already matches. That "nothing happened" is the reconciler model confirming itself, and it is what makes deploys idempotent.
-
Interrupt a big transfer and resume it — §13's machinery, live. Build a deliberately slow push, then press Ctrl+C while the file is moving:
mkdir -p bigpush dd if=/dev/zero of=bigpush/blob.bin bs=1m count=100 status=none # Linux: bs=1M rsync -avh --partial-dir=.rsync-partial --bwlimit=5000 bigpush/ localhost:"$LAB/bigdst/" # … press Ctrl-C mid-file … echo "exit=$?" ls -l "$LAB/bigdst/.rsync-partial"Expect:
exit=20— §15's SIGINT code — and a fragment namedblob.binin the partial directory, carrying the ancient mtime from §13. Re-run the identical rsync command: it uses the fragment as its delta basis and completes, and the fragment disappears.
Checkpoint 3: explain why --delete-after rather than --delete for a deploy, and what P does that --exclude does not.
-
Verify the newest snapshot really matches the source, byte for byte:
NEWEST=backups/$(ls -1 backups | grep '^2' | sort | tail -1) rsync -nic --exclude-from=excludes.txt proj/ "$NEWEST/"Expect: no itemized lines at all. Any output names a file whose contents differ —
-cread every byte on both sides to prove it. -
Corrupt the backup and prove the check catches what a normal run would not:
# same byte count as the original — 16 bytes — so only the content differs printf 'body{color:blu}\n' > "$NEWEST/assets/site.css" touch -r proj/assets/site.css "$NEWEST/assets/site.css" rsync -ni --exclude-from=excludes.txt proj/ "$NEWEST/" # quick check rsync -nic --exclude-from=excludes.txt proj/ "$NEWEST/" # checksumExpect: the first prints nothing (size and mtime match); the second prints
>fc........ assets/site.css. This is §6's lesson in your own backup. Notice what the corruption step itself just demonstrated: the>redirect wrote through the shared inode, so every snapshot hardlinking that file is now corrupt — the exact failure §14's "treat snapshots as read-only" rule exists to prevent. Fine here; the whole tree is disposable. -
Trip the deletion guard deliberately:
mkdir -p emptysrc rsync -ai --delete --max-delete=2 emptysrc/ deploy/; echo "exit=$?"Expect: at most two deletions,
Deletions stopped due to --max-delete limit, andexit=25. Confirm withls deploy/that files survived. -
Produce a real exit 23 and see what your wrapper would do with it:
rsync -a /var/db/dslocal/ "$LAB/perm/"; echo "exit=$?" # Linux equivalent: rsync -a /etc/ssl/private/ "$LAB/perm/"Expect:
Permission denied (13)on at least one path, thenrsync error: some files/attrs were not transferred … (code 23)andexit=23. That is the code your scheduled job must classify rather than blindly alert on (§15). -
Clean up:
cd / && rm -rf "$LAB" && unset LAB
Final checkpoint: you can state, without looking anything up: which exit codes a nightly backup should tolerate; why --link-dest paths are relative to the destination; what >fc........ means and which flag makes it appear; and why a deploy uses --delete-after with protect rules.
Self-assessment rubric
| Level | You can… |
|---|---|
| Working fluency | Compose -avhn-style commands, get the trailing slash right first time, use --exclude-from, and dry-run before deleting. |
| Practitioner | Read itemized output cold, choose deletion timing per job, run rsync over SSH with a restricted key, and diagnose exit codes. |
| Mastery | Build and schedule a hardlinked snapshot system with retention and guards, write filter rules that survive review, explain when the quick check is wrong, and say honestly when rsync is the wrong tool. |
Sources: every step above was taught earlier in this guide; the single upstream reference behind all of it is rsync(1), 3.4.4
18 · Troubleshooting: symptom → cause → fix
Every error message below was produced by a real run while writing this guide, or is quoted from rsync 3.4.4's manual. Find your symptom, not your theory.
Transfers that go to the wrong place
| Symptom | Cause | Fix |
|---|---|---|
Files landed in dst/src/… instead of dst/… | Missing trailing slash on the source | rsync -a src/ dst/. Read the verbose output's first file line: prefixed paths mean no slash. (§4) |
| Files went to the remote home directory, not where you meant | Remote path had no leading slash | host:/srv/app/ not host:srv/app/. (§10) |
| Destination is a file, expected a directory | Single-file source with no trailing slash on the destination | Add the slash: rsync -a f.txt out/. (§4) |
Nothing transfers, or too much does
| Symptom | Cause | Fix |
|---|---|---|
| A file you edited was not copied | Same size and same mtime — quick check skipped it | -c to compare contents. Confirm with rsync -nic first. (§6) |
| Every file re-copies on every run | Destination filesystem stores coarse timestamps (FAT/exFAT/NTFS via WSL) | --modify-window=1. (§3) |
| Every file re-copies to a NAS or NTFS target | rsync cannot set ownership/permissions, so it retries forever | --no-perms --no-owner --no-group, or --chmod=D755,F644. (§12) |
| Large file re-sends entirely every time | Local-to-local implies --whole-file | --no-whole-file when the "local" destination is a slow network mount. (§6) |
--exclude is ignored | Shell expanded the pattern before rsync saw it | Quote it: --exclude='*.log'. Diagnose with --debug=FILTER2. (§8) |
--include has no effect | An earlier --exclude matched first | Move the include before the exclude. First match wins. (§8) |
| Result is full of empty directories | --include='*/' keeps every directory | Add -m / --prune-empty-dirs. (§8) |
Permissions and metadata
| Message | Cause | Fix |
|---|---|---|
opendir "…" failed: Permission denied (13) → code 23 | Source path unreadable by you | Run with the right privileges, or exclude the path. On macOS this is often TCC, not Unix permissions. (§15) |
mkdir "…" failed: Operation not permitted (1) → code 11 | Destination is protected (SIP, read-only volume) | Choose a writable destination. Do not try to defeat SIP. |
rename "…/.f.txt.x9NXJX" -> "f.txt": Permission denied (13) | An ACL on the destination file denies delete, blocking rsync's temp-then-rename | --inplace, which writes directly and skips the rename. (§12) |
| Finder tags / Spotlight comments missing after a backup | -a does not include -X | -aX. Verify with xattr -l. (§12) |
| Destination directory's own permissions changed | Trailing-slash form transfers the source directory's attributes too | --no-perms, or --chmod to set them explicitly. (§12) |
| Backup is enormous; source has VM images | Sparse files were materialized | -S. With --inplace it may silently not sparse. (§12) |
Remote and network
| Message | Cause | Fix |
|---|---|---|
remote command not found (code 127) or could not be run (code 126) | No runnable rsync on the remote's PATH | ssh host 'command -v rsync', then --rsync-path=/full/path. (§10) |
protocol version mismatch -- is your shell clean? | The remote shell prints something at login | Verify ssh host 'true' | wc -c is 0; guard the interactive part of the remote rc file. (§10) |
connection unexpectedly closed (0 bytes received so far) | Remote rsync died at startup — missing binary, or shell output | Same two checks as above. |
an option was specified that is supported by the client and not by the server → code 4 | Version skew | Drop the flag, or upgrade the older side. (§3) |
failed to connect to localhost … Connection refused (61) → code 10 | No daemon listening on that port | Start the daemon; check the port. (§11) |
@ERROR: failed to open lock file → code 5 | max connections set without a writable lock file | Set lock file explicitly in rsyncd.conf. (§11) |
@ERROR: auth failed on module … → code 5 | Wrong credentials, or a secrets file rsync rejected | Check auth users, and that the secrets file is not readable by other. (§11) |
ERROR: module is read only → code 1 | Writing to a read only = yes module | Use a writable module. (§11) |
failed to lock pid file … Resource temporarily unavailable | A daemon is already running with that pid file | pkill -f 'rsync --daemon', then restart. |
Interruption and scheduling
| Symptom | Cause | Fix |
|---|---|---|
received SIGINT, SIGTERM, or SIGHUP (code 20), destination empty | No --partial, so the incomplete file was discarded | --partial-dir=.rsync-partial so the next run resumes. (§13) |
| Partial file has a 1969 timestamp | Deliberate — it stops the quick check treating it as complete | Nothing. Run again; rsync finishes it. |
| Backup exits 24 nightly | Source files vanished mid-run. Normal on a live system. | Treat 24 as success in your wrapper. (§15) |
| Exit 25 from a scheduled job | --max-delete tripped — often an unmounted source | Investigate before re-running. This guard did its job. (§9) |
| Works in Terminal, fails under launchd with permission errors | macOS TCC: the background job lacks Full Disk Access | Grant it to the script's interpreter. Test with launchctl kickstart, not by running the script yourself. (§15) |
| Cron job runs the wrong rsync | cron's minimal PATH excludes Homebrew | Absolute paths in scheduled jobs, or set PATH at the top of the crontab. (§15) |
| Two backups running at once | Job takes longer than its interval | flock -n, or the portable mkdir lock. (§15) |
| Out of memory on a huge tree → code 22 | -H holds an inode map for every file | Drop -H, or split the transfer. |
Two failures that look like rsync but are not
--delete; an unmounted source directory; or --delete-excluded where a plain --exclude was meant. All three are visible in a dry run, which is why the dry run is not optional. rsync has no undo.
--inplace plus an interruption leaving a file half-updated. Verify with rsync -nic src/ dst/, which reads both sides in full and names any file that actually differs.
Sources: rsync(1) — DIAGNOSTICS, EXIT VALUES · rsyncd.conf(5)
19 · Cheat sheet
Recall scaffolding, not a replacement for the sections. Every line links to where it was taught — if a flag here surprises you, that link is the fix, not this page.
The universal starting pattern
# Look, then leap. Press ↑ and delete the n.
rsync -avhn --delete SRC/ DEST/
rsync -avh --delete SRC/ DEST/
Mirror a directory
rsync -avh --delete --max-delete=100 \
~/work/ /Volumes/Backup/work/
Push over SSH
rsync -avhz --partial-dir=.rsync-partial \
~/site/ web:/srv/site/
Pull from a server
rsync -avh --info=progress2 \
web:/srv/data/ ~/data/
Deploy, protecting state
rsync -avh --delete-after \
--filter='P /uploads/' --filter='P /.env' \
./dist/ web:/srv/app/
Dated snapshot
rsync -aHAX --delete \
--link-dest=../$(ls /bk | tail -1) \
~/docs/ /bk/$(date +%F)/
Verify two trees match
rsync -nic src/ dst/
# no output = byte-identical
Move (copy then remove source)
rsync -avh --remove-source-files src/ dst/
find src -type d -empty -delete
Only certain file types
rsync -avhm --include='*/' \
--include='*.jpg' --include='*.png' \
--exclude='*' src/ dst/
WSL → Windows drive
rsync -rlt --no-perms --no-owner --no-group \
--modify-window=1 --delete \
~/proj/ /mnt/c/Users/you/proj/
Mac → Mac, keep everything
rsync -aXN --delete \
~/Documents/ /Volumes/Backup/Documents/
Diff two snapshots
rsync -ain --delete /bk/2026-08-05/ /bk/2026-08-06/
Why did it skip that file?
rsync -ain --debug=FILTER2 src/ dst/ 2>&1 \
| grep hiding
Flag reference
| Flag | Meaning | Section |
|---|---|---|
-a | -rlptgoD: recurse + preserve links, perms, times, group, owner, devices | §5 |
-v -vv | verbose; twice for more | §7 |
-h | human-readable sizes | §5 |
-n | dry run | §7 |
-i | itemize changes | §7 |
-c | compare by checksum, not size+mtime | §6 |
-u | skip files newer on the receiver | §6 |
-z --zc=zstd | compress in flight; choose algorithm | §13 |
-P | --partial --progress | §13 |
-H -A -X -N -S | hard links · ACLs · xattrs · creation times · sparse | §12 |
-L --safe-links | follow symlinks · drop escaping ones | §12 |
-m | prune empty directories | §8 |
-R | preserve the source path; /./ sets the pivot | §12 |
-e --rsync-path | remote shell · remote rsync binary | §10 |
-s | send filenames over the protocol, not the shell | §10 |
--delete --delete-after --delete-excluded | remove extraneous destination files | §9 |
--max-delete=N | stop after N deletions, exit 25 | §9 |
--backup --backup-dir=D | move displaced files aside instead of losing them | §9 |
--exclude= --exclude-from= -F | filter rules: inline · from a file · per-directory | §8 |
--filter='P pat' | protect destination paths from deletion | §9 |
--link-dest=D | hard-link unchanged files from D | §14 |
--partial-dir=D | keep interrupted fragments in D | §13 |
--inplace | write directly; no temp file, no atomicity | §13 |
--modify-window=N | treat mtimes within N seconds as equal | §3 |
--chmod=D755,F644 | impose permissions rather than copy them | §12 |
--files-from=F | transfer exactly the listed paths | §12 |
--mkpath | create missing destination path components | §12 |
--bwlimit=N | throttle to N KiB/s (suffixes allowed) | §13 |
--stats --info=progress2 | summary · one running total | §7 |
--debug=FILTER2 | explain which rule hid which file | §8 |
--no-whole-file | force delta transfer on a local copy | §6 |
Itemize codes at a glance
YXcstpoguax path
│││││││││││
││└───────── c checksum differs (needs -c) / changed symlink value
││ └──────── s size differs
││ └─────── t mtime differs (T = set to transfer time)
││ └────── p permissions differ
││ └───── o owner differs
││ └──── g group differs
││ └─── u atime (u) · crtime (n) · both (b) — needs -U / -N
││ └── a ACL differs (needs -A)
││ └─ x xattrs differ (needs -X)
│└────────── X f file · d dir · L symlink · D device · S special
└─────────── Y > received · < sent · c created · h hardlinked
. attrs only · * message (e.g. *deleting)
Sources: rsync(1) — OPTION SUMMARY
Glossary
- archive mode
-a, equivalent to-rlptgoD. Recursion plus preservation of symlinks, permissions, times, group, owner, and device/special files. Excludes ACLs, xattrs, atimes, crtimes, and hard-link detection. (§5)- basis file
- The existing destination file the delta algorithm describes and builds upon. Without one, a transfer is entirely literal. Supplied implicitly by an existing file, or explicitly via
--link-dest,--compare-dest, or--fuzzy. (§6) - block size
- The chunk length the delta algorithm divides files into. Scales with file size by default; forced with
-B. Determines the granularity of what counts as "unchanged". (§6) - daemon mode
- rsync running as a server on TCP 873 (by default), publishing named modules. Addressed with
host::moduleorrsync://host/module. Distinct from SSH transport. (§11) - delta-transfer algorithm
- rsync's namesake: the receiver sends checksums describing the blocks it already has; the sender transmits only unmatched literal data plus references. Disabled by default for local-to-local copies. (§6)
- dry run
-n. Makes every decision, performs none. Output is a plan, not a transcript — it reports directories it did not create. (§7)- extended attribute (xattr)
- Out-of-band metadata attached to a file. On macOS holds Finder tags, Spotlight comments, quarantine flags, and resource forks. Preserved only with
-X. (§12) - extraneous file
- A file present at the destination but absent from the sender's file list. What
--deleteremoves. Note that filter rules shape the file list first. (§9) - file list
- The set of paths rsync intends to reconcile, built by walking the source and applying filter rules. Streamed incrementally in rsync 3.x, which is why large transfers start immediately. (§7)
- filter rule
- An include, exclude, protect, or hide directive. Evaluated in order; first match wins. Expressed via
--exclude,--include,--filter,--exclude-from, or per-directory merge files. (§8) - generator
- The rsync role that compares the file list against the destination and decides what to skip, update, create, or delete. Emits block checksums for the sender. (§1)
- hard link
- A second directory entry pointing at the same inode. Costs no additional data blocks. The basis of
--link-destsnapshots and the reason-Hexists. (§14) - in-place write
--inplace. Writes updates directly to the destination file rather than to a temporary file that is renamed. Saves space and preserves hard links and ACLs; forfeits atomicity. (§13)- itemized change string
- The eleven-character code emitted by
-i, e.g.>f.st....... Positions 1–2 give direction and file type; 3–11 name which attributes differ. (§7) - literal data
- Bytes that had to be sent because no matching block existed at the destination. Reported by
--statsalongside matched data. (§6) - merge file
- A per-directory filter file, conventionally
.rsync-filter, read when rsync enters that directory. Enabled with-For--filter='dir-merge …';-FFalso excludes the file itself. (§8) - module
- A named, path-mapped share published by an rsync daemon, defined by a
[section]inrsyncd.conf. Carries its own permissions and optional authentication. (§11) - openrsync
- An independent rsync reimplementation originating in OpenBSD and shipped by Apple as
/usr/bin/rsyncon current macOS. Speaks protocol 29. Lacks-X,--info=progress2, and modern compression; its-ioutput uses the older 9-character format. (§3) - partial file
- An incomplete transfer preserved by
--partialor--partial-dir, deliberately given an ancient mtime so the quick check cannot mistake it for complete. Becomes the basis on the next run. (§13) - protocol version
- The wire-format level two rsyncs negotiate. 3.4.4 speaks 32; openrsync speaks 29. Mismatches negotiate down; unsupported options, not protocol, are what actually fail. (§3)
- quick check
- rsync's default test for whether a file needs transferring: size differs or mtime differs. Contents are not read. Overridden by
-c,--size-only, or-I. (§6) - receiver
- The rsync role that reassembles files at the destination, writing to a hidden temporary file and renaming it into place. (§1)
- rolling checksum
- A cheap checksum computable incrementally as a window slides one byte at a time. Lets the sender find matching blocks at arbitrary offsets, so inserted bytes do not defeat matching. (§6)
- sender
- The rsync role that reads the source tree, builds the file list, and computes deltas. Announced as
--senderin the remote invocation when you are pulling. (§1) - snapshot (hardlinked)
- A dated directory produced with
--link-dest: a complete browsable tree whose unchanged files are hard links to the previous snapshot, so they cost no extra space. (§14) - sparse file
- A file whose zero runs are not stored as blocks. Preserved with
-S; without it, holes are materialized into real bytes. (§12) - trailing slash
- On a source path, means "the contents of this directory" rather than "this directory". Irrelevant on a destination path for directory sources; significant for single-file sources. (§4)
- transfer root
- The directory that anchored filter patterns are relative to — the source as rsync sees it after applying the trailing-slash rule.
/buildmeans "build at the transfer root". (§8) - whole-file mode
-W. Sends files entire, skipping delta computation. Implied for local-to-local copies, where reading both files costs more than writing one. (§6)
Index
- A
-a,--archive— §5, §12-A,--acls— §12- ACL rename failure (macOS) — §12, §18
- anchoring, pattern — §8
--atimes,-U— §12- authorized_keys, forced command — §15
- B
--backup,--backup-dir— §9--block-size,-B— §6--bwlimit— §10, §13- C
-c,--checksum— §6--chmod— §12--chown— §12--compare-dest,--copy-dest— §14--compress,-z— §13--compress-choice,--zc— §13--config(daemon) — §11--contimeout— §15- ControlMaster (SSH) — §10
--crtimes,-N— §12- cron — §15
- D
- daemon,
--daemon— §11 - daemon over SSH — §11
--debug=FILTER2— §8--deleteand variants — §9--delete-after— §9--delete-excluded— §9- delta-transfer algorithm — §6
- direction (
>vs<) — §7, §11 --dry-run,-n— §7- E
-e,--rsh— §10- empty-source catastrophe — §9
--exclude,--exclude-from— §8- exit codes (full table) — §15
- exit 23 / 24 / 25 — §15, §9
- exit 126 / 127 (remote command) — §10
- F
-F,-FF(merge files) — §8--fake-super,-M--fake-super— §12--files-from— §12--filter— §8- first match wins — §8
- flock / portable lock — §15
- Full Disk Access (macOS TCC) — §15
--fuzzy,-y— §14- G
- generator (role) — §1
- glob expansion, remote — §10
--groupmap,--usermap— §12- H
-H,--hard-links— §12- hard link — §14
- Homebrew rsync — §3
- I
-i,--itemize-changes— §7--iconv— §12--ignore-existing,--existing— §6--include— §8--info=progress2— §7, §13--inplace— §12, §13- L
-L,--copy-links— §12- launchd — §15
--link-dest— §14- lock file (rsyncd.conf) — §11
--log-file-format— §15- M
-m,--prune-empty-dirs— §8- man page, wrong (macOS) — §3
--max-delete— §9--mkpath— §12-M,--remote-option— §12--modify-window— §3, §6- module (daemon) — §11
--munge-links— §11- N
--no-whole-file— §6--no-detach(daemon) — §11--no-perms,--no-owner,--no-group— §3, §12--numeric-ids— §12- O
- openrsync — §3
- P
-P— §5--partial,--partial-dir— §13--progress— §7- protect rule (
P) — §9 - protocol version — §3
- Q
- quick check — §6
- quoting patterns — §2, §8
- R
-R,--relative— §12- receiver (role) — §1
--remove-source-files— §9- rolling checksum — §6
--rsync-path— §10rsyncd.conf— §11RSYNC_PASSWORD— §11- S
-s,--secluded-args— §10-S,--sparse— §12--safe-links— §12- secrets file — §11
- sender (role) — §1
--server(internal) — §10- shell, chatty remote — §10
--size-only— §6--skip-compress— §13- snapshot script — §14
- SSH key setup — §10
--stats— §7--stop-after,--stop-at— §13- systemd timer — §15
--timeout— §15- T
- trailing slash — §4
- transfer root — §8
- U
-u,--update— §6- W
-W,--whole-file— §6- WSL
/mnt/c— §3, §12 - X
-X,--xattrs— §12- Z
-z,--zc,--zl— §13
Retrieval quiz
Answer out loud before revealing. Retrieval builds durable memory in a way re-reading does not — this is the highest-value ten minutes in the guide. Aim for 16/19 before you consider rsync learned; revisit a day later and again a week later.
--delete, idempotency, and the trailing slash at once?--delete), a second run finds nothing to do, and the slash answers "which destination path should hold this source?" (§1)-c. (§6)rsync -a src dst/ versus rsync -a src/ dst/ — what does each produce, and does the destination slash matter?dst/src/…. Source slash → contents land directly in dst/…. The destination slash is irrelevant for directory sources; it matters only when the source is a single file and the destination does not yet exist (file vs directory). (§4)-a expand to, and name three things it does not preserve.>f.st...... and .f...p...... Did bytes cross the wire in each case?>f) whose size and mtime differed — yes, data transferred. Second: leading . means no transfer; only the permissions were adjusted on an existing file. (§7)--whole-file: computing deltas means reading both files, which costs more than writing one when both are on your disks. Add --no-whole-file when the "local" destination is really a slow network mount. Measured: 10,485,760 literal bytes versus 1,152. (§6)--include has no effect. What is the most likely reason?--exclude matched first. Filter rules are evaluated in order and first match wins; an include only helps when it precedes the exclude that would catch the file. (§8).md files out of a deep tree, leaving no empty directories.rsync -avm --include='*/' --include='*.md' --exclude='*' src/ dst/. The */ include lets rsync descend; -m prunes the directories that end up empty. (§8)ssh host rsync --server … — verified as rsync --server -vlogDtpre.iLsfxCIvu . /path/, with --sender added when pulling. The only dependency is an rsync binary on the remote's PATH; no daemon, no port. (§10)protocol version mismatch -- is your shell clean? What is wrong and how do you confirm it?ssh host 'true' | wc -c; it must be 0. Fix by guarding the interactive part of the remote rc file. (§10)host:/srv/data/ versus host::data/ — what is the difference?rsync -a --delete do, and what stops it?--max-delete=N (exits 25 after N deletions), a mount-marker guard in the script, and a dry run before any edited --delete command. (§9)--link-dest give you a hundred full backups for roughly the size of one, and what is --link-dest's path relative to?~/Documents. What is happening?Operation not permitted. Grant Full Disk Access to the script's interpreter and test with launchctl kickstart — running the script yourself inherits Terminal's permissions and succeeds misleadingly. (§15)rsync --version says 3.4.4 but man rsync disagrees with it. Why, and what do you do?man -w rsync resolves to /usr/share/man/man1/openrsync.1 — Apple's page for its own implementation — regardless of PATH. Read the right one: man "$(brew --prefix rsync)/share/man/man1/rsync.1", or fix MANPATH. (§3)tar piped over ssh. rsync is a one-shot reconciler you invoke, and it is unbeaten at exactly that. (§16)--partial, the incomplete file is discarded and the next run starts that file over. Bare --partial keeps the fragment at its final name — on a live site you have just published a truncated file. --partial-dir=.rsync-partial keeps the fragment hidden, stamped with an ancient mtime, and the next run uses it as the delta basis. (§13)-z compression make a transfer faster, and when is it pure cost?--skip-compress covers mixed trees. (§13)Sources
Checked with rsync 3.4.4, protocol version 32 (Homebrew) and openrsync, protocol version 29 (Apple, /usr/bin/rsync) on macOS 15.6 · 2026-08-07. Every transcript in this guide was captured from a real run in a temporary directory; where a mechanism could not be reproduced locally it is labelled as such. Remote-shell transcripts were produced with a logging stand-in for ssh, so the --server argv is genuine while the transport is a local pipe. External links require network access; the guide itself works offline.
- rsync(1) manual page — the authoritative reference for every flag in this guide. Best used for: exact option semantics, FILTER RULES, and EXIT VALUES. Read this one, not the openrsync page macOS gives you.
- rsyncd.conf(5) — daemon configuration. Best used for: module parameters,
auth users, secrets file rules, chroot andstrict modes. - The rsync algorithm — Tridgell & Mackerras technical report — the original 1996 paper. Best used for: understanding rolling checksums and why block matching survives insertions. Short, readable, and still accurate.
- rsync home page — Best used for: confirming the current stable version and reading release announcements. 3.4.4 was released 2026-06-08.
- rsync NEWS file — Best used for: what changed between your version and current, and which CVEs a release fixed. 3.4.0 fixed six; 3.4.3 fixed six more.
- openrsync(1) — Best used for: knowing what Apple's
/usr/bin/rsyncactually supports, when you cannot install Homebrew rsync. - Ubuntu rsync package versions — Best used for: confirming which rsync your Ubuntu or WSL release ships before you rely on a version-gated flag.
- CVE-2024-12084 (NVD) — Best used for: the concrete reason to run a patched rsync if you ever connect to a server you do not control. Heap buffer overflow in checksum parsing, fixed in 3.4.0.
- CVE-2026-29518 (NVD) — Best used for: understanding why
use chroot = nois a real risk in daemon mode. TOCTOU symlink race, fixed in 3.4.3. - systemd.timer(5) — Best used for:
OnCalendarsyntax,Persistent=truesemantics for laptops, and randomized delays. - Apple — Creating launchd jobs — Best used for: plist keys and the modern
launchctl bootstrap/bootoutverbs. Archived but still the clearest description of the model. - ssh_config(5) — Best used for:
ControlMasterconnection reuse andProxyJump, both of which belong in your config rather than in-estrings.