rsync
rsync for daily SRE work: trailing-slash semantics, what -a leaves out, the quick check and delta transfer, reading itemised dry runs, the --delete family, filter rules, ssh/rrsync/daemon transports, resumable transfers, --link-dest snapshots, and the openrsync-on-macOS version gap.
rsync
Incremental file copying and mirroring over ssh or locally: what gets transferred, what gets deleted, and how to see both before it happens.
Overview
rsync copies a tree from a source to a destination, transferring only what differs. It decides which files to touch with a cheap metadata comparison (the "quick check"), and for those files it can send only the changed blocks (the delta-transfer algorithm). Everything else — --delete, filters, --link-dest snapshots — is layered on that.
It is also one of the easiest tools in the box to hurt yourself with. The things that bite, in rough order of damage done:
--deleteagainst an empty or unmounted source mirrors the emptiness. rsync exits 0 and the destination is gone.- The trailing slash on the source decides whether you copy the directory or its contents. Combined with
--delete, getting it wrong deletes the wrong level. - Filter rules are first-match-wins, and an excluded directory is never descended into — so an include listed after it, or beneath it, does nothing.
-ais not "everything": hard links (-H), ACLs (-A), xattrs (-X), and sparseness (-S) are all left out./usr/bin/rsyncon macOS is not rsync. It is openrsync, speaking protocol 29, and it lacks a long list of flags you will reach for.
Behaviour below was checked against rsync 3.5.0 (protocol 32) on Debian 13 with security updates applied, and against openrsync (protocol 29) on macOS where the two differ. Run rsync --version before trusting a flag on a host you have not used before.
Always dry-run anything with
--delete.rsync -ain --delete SRC/ DEST/costs seconds and shows every deletion as a*deletingline. The rest of this sheet assumes you do.
Source and Destination Paths
Key Concepts
- A trailing slash on the source means "the contents of this directory". No trailing slash means "this directory itself", so it is recreated inside the destination.
- The destination's trailing slash does not matter when the source is a directory.
dstanddst/behave identically. - The destination slash does matter for a single-file copy to a path that does not exist yet:
file newcreates a file callednew;file new/creates a directorynewcontainingfile. - rsync creates only the final missing component of the destination. Anything deeper needs
--mkpath(3.2.3+).
The Trailing Slash
rsync -a src dst # -> dst/src/a.txt (directory itself)
rsync -a src dst/ # -> dst/src/a.txt (same: dest slash irrelevant)
rsync -a src/ dst # -> dst/a.txt (contents)
rsync -a src/ dst/ # -> dst/a.txt (same)
rsync -a src/. dst # -> dst/a.txt (same as src/)
# Single file, destination does not exist yet:
rsync -a src/a.txt new # -> a FILE called new
rsync -a src/a.txt new/ # -> new/a.txt
Write the slash on both sides, every time (SRC/ DEST/), and the command means "make DEST look like SRC". That is the form --delete is safe to reason about.
Mixing forms in one command does what each argument says — rsync -a s s/ dest/ puts dest/a and dest/s/a — which is rarely what anyone wanted.
Shell Globs Are Not the Same Thing
rsync -a --delete src/* dst/ # WRONG: deletes nothing, and skips dotfiles
The shell expands src/* before rsync starts, so rsync is handed a list of individual items rather than a directory. --delete only acts inside directories rsync was asked to synchronise, so stale files at the top of dst/ survive — and * does not match dotfiles in the first place. Use src/.
Missing Destination Parents
rsync -a src/ /backup/a/b/c/
# rsync: [Receiver] mkdir "/backup/a/b/c" failed: No such file or directory (2)
# rsync error: error in file IO (code 11) at main.c(808) [Receiver=3.5.0]
rsync -a --mkpath src/ /backup/a/b/c/ # 3.2.3+: like mkdir -p on the receiver
--mkpath creates the missing components with default permissions. To make them mirror the source's directories instead, use -R (--relative) with a /./ marker to say where the preserved path starts:
rsync -aR /srv/www/./site-a/assets/ host:/backup/ # -> /backup/site-a/assets/
What -a Does and Does Not Do
Key Concepts
-a (--archive) is exactly -rlptgoD:
| Flag | Preserves | Notes |
|---|---|---|
-r |
— | Recurse. Not implied when --files-from is used. |
-l |
Symlinks as symlinks | |
-p |
Permissions | |
-t |
Modification times | The quick check depends on this — see below |
-g |
Group | Only to groups the receiving user belongs to, unless root |
-o |
Owner | Silently ignored unless the receiver is root |
-D |
Devices and specials | --devices --specials; devices need root |
What It Leaves Out
| Flag | Adds | Consequence of omitting it |
|---|---|---|
-H |
Hard links | Each link becomes an independent full copy |
-A |
POSIX ACLs | ACLs dropped (-A implies -p) |
-X |
Extended attributes | user.* xattrs, SELinux labels, capabilities dropped |
-S |
Sparse handling | Holes are written out as real zeros |
-U / -N |
atimes / crtimes | Not preserved |
Measured on Debian 13, copying a directory holding one hard-linked pair, a 100 MiB sparse file, and a file with a user.note xattr:
link count sparse file blocks xattrs
source 2 0 ['user.note']
rsync -a 1 204800 []
rsync -aHXS 2 0 ['user.note']
-a turned 0 blocks of sparse file into 100 MiB of zeros and split the hard link. For a system or container-image backup the baseline is -aHAXS (add --numeric-ids across hosts — see below).
Ownership as a Non-Root User
$ stat -c '%U:%G' src/rootfile
root:root
$ rsync -a src/ dst/ ; echo $? # run as an ordinary user
0
$ stat -c '%U:%G' dst/rootfile
mike:mike
-o cannot work without root on the receiving side, and rsync does not complain. If the copy must restore ownership later, either run the receiver as root (--rsync-path="sudo rsync") or record it with --fake-super.
How rsync Decides What to Transfer
The Quick Check
For every file in the sender's list, the generator on the receiving side asks one question: does a file with the same size and the same modification time already exist here? If so the file's data is not looked at.
flowchart TD
A["File in the sender's list"] --> B{"Exists on receiver?"}
B -->|"No"| T["Transfer"]
B -->|"Yes"| C{"--ignore-existing, or<br/>-u and receiver copy is newer?"}
C -->|"Yes"| S["Skip entirely"]
C -->|"No"| D{"Size differs?"}
D -->|"Yes"| T
D -->|"No"| E{"-c / --checksum given?"}
E -->|"No"| G{"mtime differs?<br/>(whole seconds)"}
E -->|"Yes"| F{"Whole-file checksum differs?"}
G -->|"Yes"| T
G -->|"No"| U["No data sent;<br/>fix perms/owner/times only"]
F -->|"Yes"| T
F -->|"No"| U
| Flag | Effect on the decision |
|---|---|
| (default) | Size + mtime |
-c, --checksum |
Size, then a full checksum of the file on both sides. Reads every byte of every same-sized file — slow, I/O-heavy, and the only mode that catches silent content changes |
--size-only |
Size only; mtime ignored |
-I, --ignore-times |
No quick check: every file is transferred |
-u, --update |
Skip files whose receiver copy is newer |
--existing |
Never create new files, only update existing ones |
--ignore-existing |
Never touch files that already exist |
--modify-window=N |
Treat mtimes within N seconds as equal (1 for FAT/exFAT targets) |
Three consequences worth internalising:
-
Without
-t(or-a) the quick check never settles. The receiver's files get the transfer time as mtime, never match the source, and are re-sent on every run. -
The mtime comparison is in whole seconds. A same-size rewrite within the same second as the original mtime is invisible:
src/f mtime 16:03:23.999 content BBBB dst/f mtime 16:03:23.872 content AAAA $ rsync -ai src/ dst/ # no output — nothing transferred $ rsync -aic src/ dst/ >fc........ f -
-cis not "verify the copy". rsync already verifies every transferred file with a whole-file checksum after reconstruction.-cchanges how it selects files, not how carefully it copies them.
Delta Transfer and --whole-file
Once a file is selected, the delta-transfer algorithm sends only the blocks the receiver's existing copy lacks. It is on by default for remote transfers and off by default when both paths are local (--whole-file is implied), because reading the old copy to compute checksums usually costs more than just writing the new one.
One byte flipped in the middle of a 20 MiB file, local copy:
$ rsync -a --stats src/ dst/ # local default = --whole-file
Literal data: 20,971,520 bytes
Matched data: 0 bytes
$ rsync -a --no-whole-file --stats src/ dst/ # force the delta algorithm
Literal data: 4,576 bytes
Matched data: 20,966,944 bytes
Use -W for remote transfers on a fast LAN where CPU or disk, not bandwidth, is the limit. Use --no-W locally only when minimising writes matters (with --inplace, onto copy-on-write or snapshotted storage).
The Pipeline
Three roles do the work. The sender runs wherever the source is; the generator and receiver run wherever the destination is. Over ssh, one side of that split is the rsync --server process started on the remote host.
flowchart LR
subgraph SRC["Sending side"]
S["Sender<br/>walks the source, builds the file list,<br/>computes deltas"]
end
subgraph DST["Receiving side"]
G["Generator<br/>quick check, deletions,<br/>block checksums of the existing copy"]
R["Receiver<br/>rebuilds into a temp file,<br/>verifies, renames into place"]
end
S -->|"1. file list"| G
G -->|"2. wanted files + block checksums"| S
S -->|"3. literal data + block references"| R
G -.->|"existing copy is the basis"| R
The receiver writes each file to a hidden temporary name in the destination directory (observed: .big.bin.EvSPZC) and renames it over the old one when complete. So an updated file is a new inode: readers never see a half-written file, a hard link elsewhere to the old copy is left pointing at the old data, and the filesystem needs room for both copies at once. --inplace changes all three (see Large and Interrupted Transfers).
Reading a Dry Run
Key Concepts
-n (--dry-run) does everything except write. -i (--itemize-changes) prints one line per item that would change, prefixed with an eleven-character string. Together — -ain — they are the pre-flight check for any sync. Unchanged files print nothing; a clean tree gives empty output and exit 0.
$ rsync -ain --delete src/ dst/
*deleting extra.txt
>f.st...... change.txt
cLc.t...... link -> change.txt
>f+++++++++ new.txt
.f...p..... perm.txt
>f..t...... touch.txt
*deleting dir/x
.d..t...... dir/
The Eleven Characters: YXcstpoguax
| Pos | Char | Meaning |
|---|---|---|
| 1 (Y) | < / > |
File data is being sent / received |
c |
Local change or creation (new directory, symlink, device) | |
h |
Hard link to another item in the transfer (needs -H) |
|
. |
No data transfer; attributes may still change | |
* |
The rest is a message, e.g. *deleting |
|
| 2 (X) | f d L D S |
File, directory, symlink, device, special |
| 3 | c |
Checksum differs (regular files: only with -c); or a symlink/device changed value |
| 4 | s |
Size differs |
| 5 | t / T |
mtime differs and will be set to the sender's / to the transfer time (no -t) |
| 6 | p |
Permissions |
| 7 | o |
Owner |
| 8 | g |
Group |
| 9 | u / n / b |
atime / crtime / both |
| 10 | a |
ACL |
| 11 | x |
Extended attributes |
In positions 3–11: . unchanged, + newly created (the whole string is +), a space when nothing changed (only visible with -ii).
Lines you will see constantly:
| Line | Reading |
|---|---|
>f+++++++++ new.txt |
New file |
>f.st...... change.txt |
Content changed (size and mtime differ) |
>f..t...... touch.txt |
Same size, different mtime — the file is still processed (whole file locally, delta remotely); only -c can tell it is identical |
.f...p..... perm.txt |
Only permissions differ; no data sent |
>fc........ f |
Same size and mtime, different content — only ever shown with -c |
cd+++++++++ dir/ |
New directory |
.d..t...... dir/ |
Directory mtime only; harmless noise (-O suppresses it) |
hf+++++++++ a => b |
a created as a hard link to b |
cLc.t...... link -> x |
Symlink target changed |
*deleting extra.txt |
Will be removed by --delete |
Useful variations: -ii also lists unchanged items; --out-format='%i %n%L' is what -i expands to; --stats adds totals including Number of deleted files:; --info=help and --debug=help list the fine-grained output flags (3.1.0+). openrsync prints a nine-character string (>f+++++++, .d..t....) — no ACL/xattr columns — so do not parse it with a fixed-width pattern written for rsync.
The --delete Family
Key Concepts
--deleteremoves anything in the destination that is not in the source, but only inside directories being synchronised. It needs-r(or-d); without it rsync refuses with a usage error.- Excluded files are protected from deletion. An
--exclude='*.log'both hides logs on the sender and shields logs on the receiver.--delete-excludedremoves that shield. - If none of the timing variants is given, 3.x talking to 3.x uses
--delete-during.
Timing Variants
| Flag | When | Notes |
|---|---|---|
--delete-before |
Whole-tree scan and delete, then transfer | Frees space first. Forces the full file list into memory up front (no incremental recursion) |
--delete-during / --del |
Per directory, just before that directory's files | The default. Cheapest |
--delete-delay |
Found during, removed after the transfer | Pair with --delay-updates — which, like -m, also disables incremental recursion |
--delete-after |
Separate pass after the transfer | Also disables incremental recursion |
--delete-excluded |
— | Also delete receiver files matching your excludes |
--max-delete=N |
— | Stop after N deletions and exit 25 |
--ignore-errors |
— | Delete even after source I/O errors. Almost never what you want |
The output order shows the difference. Same tree, two modes:
$ rsync -ain --delete src/ dst/ $ rsync -ain --delete-after src/ dst/
*deleting stale1 >f+++++++++ a
>f+++++++++ a >f+++++++++ d/b
*deleting d/stale2 *deleting stale1
>f+++++++++ d/b *deleting d/stale2
The Empty-Source Wipe
A source that does not exist is an error and deletes nothing:
$ rsync -a --delete -i /mnt/data/nosuch/ dst/
rsync: [sender] change_dir "/mnt/data/nosuch" failed: No such file or directory (2)
rsync error: some files/attrs were not transferred (see previous errors) (code 23) ...
A source that exists but is empty — a mount point whose filesystem did not mount, an NFS export that came up bare, a variable that expanded to the wrong directory — is a perfectly valid instruction to empty the destination:
$ rsync -a --delete -i /mnt/data/ dst/ # /mnt/data is an unmounted mount point
*deleting x2
*deleting x1
*deleting c
*deleting b
*deleting a
.d..t...... ./
$ echo $?
0
Guards, cheapest first — use at least two in any unattended job:
# 1. Refuse to run unless the source is really mounted
mountpoint -q /mnt/data || { echo "/mnt/data not mounted" >&2; exit 1; }
# 2. Require a sentinel file that only exists on the real filesystem
[ -e /mnt/data/.rsync-sentinel ] || { echo "sentinel missing" >&2; exit 1; }
# 3. Cap the damage: more than N deletions aborts the deleting and exits 25
rsync -a --delete --max-delete=500 /mnt/data/ host:/backup/data/
# 4. Delete nothing (3.0.0+): prints a count of skipped deletions and exits 25 if anything is extraneous
rsync -a --delete --max-delete=0 /mnt/data/ host:/backup/data/
$ rsync -a --delete --max-delete=2 -i src/ dst/
*deleting x4
*deleting x3
Deletions stopped due to --max-delete limit (2 skipped)
rsync error: the --max-delete limit stopped deletions (code 25) at main.c(1394) [sender=3.5.0]
The mirror-image mistake is an unmounted destination: rsync happily fills the root filesystem underneath the mount point. Guard both ends.
The I/O-Error Guard Is Weaker Than It Sounds
rsync disables deletion when the sender hits an I/O error (IO error encountered -- skipping file deletion), so a half-readable source does not prune the destination. But with the default --delete-during, deletions in directories processed before the error is met have already happened. With an unreadable src/locked/ and stale files at the top level and in zdir/:
| Mode | Top-level stale |
zdir/stale2 |
|---|---|---|
--delete (during) |
deleted | kept |
--delete-delay |
deleted | kept |
--delete-before |
kept | kept |
--delete-after |
kept | kept |
If "never delete on a partly unreadable source" matters, use --delete-after (or -before) and accept the memory cost.
Filter Rules
Key Concepts
- Rules are checked in the order given; the first match wins. A file that matches nothing is included.
- rsync walks the tree top-down and never descends into an excluded directory. To include something deep, every parent directory must be included first.
- A leading
/anchors the pattern to the root of the transfer — not the filesystem root. The transfer root is the directory whose contents are being sent, so it shifts with the source's trailing slash. - A trailing
/matches directories only. - A pattern with no
/matches the final path component at any depth. A pattern with an interior/is matched against the whole path, and unless anchored it can match at the tail of any path. *stops at/;**crosses it;dir/***matches the directory and everything beneath it.
Ordering
# Tree: a.log b.txt logs/x.log logs/deep/y.log data/keep/z.bin ...
rsync -a --include='*.log' --exclude='*' src/ dst/
# -> a.log only. logs/ was excluded by '*', so logs/x.log was never examined.
rsync -a --exclude='*' --include='*.log' src/ dst/
# -> nothing. '*' matched first, every time.
rsync -a --include='*/' --include='*.log' --exclude='*' src/ dst/
# -> every .log at any depth, plus EVERY directory, empty or not.
rsync -am --include='*/' --include='*.log' --exclude='*' src/ dst/
# -> every .log, and only the directories that lead to one. (-m = --prune-empty-dirs)
Include One Subtree
# Only data/keep/ and everything under it
rsync -a --include='/data/' --include='/data/keep/***' --exclude='*' src/ dst/
# The parent line is not optional — without it the result is empty:
rsync -a --include='/data/keep/***' --exclude='*' src/ dst/ # transfers nothing
Anchoring and Wildcards
Tree containing foo/bar.txt, sub/foo/bar.txt, logs/x.log, logs/deep/y.log:
| Rule | Excludes |
|---|---|
--exclude='foo' |
foo/ and sub/foo/ |
--exclude='/foo' |
Only the top-level foo/ |
--exclude='foo/bar.txt' |
foo/bar.txt and sub/foo/bar.txt |
--exclude='/foo/bar.txt' |
Only foo/bar.txt |
--exclude='logs/' |
The directory logs/ and so everything in it |
--exclude='logs/*.log' |
logs/x.log only; logs/deep/y.log survives |
--exclude='logs/**.log' |
Both |
--exclude='logs/**' |
Everything inside logs/; the empty logs/ is still created |
The anchor moves with the slash: with rsync -a src/ dst/ the top-level directory is /foo; with rsync -a src dst/ the transfer root is one level up and the same directory is /src/foo. --exclude='/foo' matches nothing in the second form.
When a rule set misbehaves, ask rsync which rule fired:
$ rsync -an --debug=FILTER --include='*.log' --exclude='*' src/ dst/
[sender] hiding file b.txt because of pattern *
[sender] showing file a.log because of pattern *.log
[sender] hiding directory logs because of pattern *
--files-from
# Paths in the list are relative to the source argument; -R is implied,
# so the directory structure is recreated under the destination.
rsync -a --files-from=list.txt /srv/data/ host:/backup/data/
# -a does NOT imply -r here. A directory named in the list is created empty
# unless you add -r explicitly:
rsync -a -r --files-from=list.txt /srv/data/ host:/backup/data/
find . -name '*.conf' -print0 | rsync -a -0 --files-from=- . host:/backup/conf/
Rule Files and Per-Directory Filters
rsync -a --exclude-from=excludes.txt src/ dst/ # one pattern per line
rsync -a --filter='merge rules.txt' src/ dst/ # full filter syntax in a file
rsync -a -F src/ dst/ # honour a .rsync-filter file in each directory
rsync -a -FF src/ dst/ # ...and do not transfer the .rsync-filter files themselves
# rules.txt / .rsync-filter — full filter syntax
- *.tmp
- /build/
P /cache/
| Prefix | Long form | Meaning |
|---|---|---|
- / + |
exclude / include |
Hide or show on the sender, and protect or expose on the receiver |
H / S |
hide / show |
Sender side only |
P / R |
protect / risk |
Receiver side only: shield from, or expose to, --delete |
. |
merge |
Read more rules from a file |
: |
dir-merge |
Read a named file from each directory as it is entered |
! |
clear |
Discard the rules so far |
P is the one to remember: --filter='P /cache/' keeps a destination-only directory alive under --delete without affecting what is sent. The flip side is that a plain exclude protects too — an include-list ending in - * shields every unlisted file on the receiver, so --delete removes nothing outside the included paths unless you add --delete-excluded.
A .rsync-filter applies to its own directory and below, and an anchored pattern in it is anchored to that directory: - /foo in sub/.rsync-filter excludes sub/foo but neither the top-level foo nor sub/deeper/foo.
Transports
ssh
A single colon — host:path — means "run rsync on that host over a remote shell", which is ssh unless told otherwise. rsync must be installed on both ends; the remote side is started as rsync --server ….
rsync -a src/ app1:/srv/app/ # push; app1 can be an ssh config alias
rsync -a app1:/var/log/app/ ./logs/ # pull
rsync -a -e 'ssh -p 2222 -i ~/.ssh/id_ed25519_backup' src/ backup@host:/data/
rsync -a -e 'ssh -J bastion' src/ deploy@10.0.4.17:/srv/app/
export RSYNC_RSH='ssh -o BatchMode=yes' # default for -e; BatchMode for cron
rsync simply executes ssh, so everything in ~/.ssh/config applies: host aliases, ProxyJump, IdentityFile, and — the one that matters for loops and many small runs — connection multiplexing, which removes the per-invocation handshake. Configure those once in ssh rather than in every -e; see SSH & SSH Config for ProxyJump, ControlMaster/ControlPersist, and key handling.
Two things ssh does not do for you:
- Anything printed by the remote shell's startup files corrupts the protocol. An
echoin the remote.bashrcgivesprotocol version mismatch -- is your shell clean?(exit 2). Guard interactive-only output with a[[ $- == *i* ]]test. - Remote paths with spaces. Since 3.2.4 rsync backslash-escapes arguments for the remote shell itself, so
host:'/srv/dir with space/'just works. Older clients need the path quoted twice or-s;--old-argsrestores the old splitting for scripts that relied on it.
Root on the Far Side: --rsync-path
# Run the remote half under sudo — needs NOPASSWD, there is no tty to prompt on
rsync -aHAX --numeric-ids --rsync-path='sudo rsync' /etc/ backup@host:/backup/etc/
--rsync-path replaces the remote command name, so it is also how you point at a non-default binary (--rsync-path=/opt/homebrew/bin/rsync). Scope the sudoers rule to /usr/bin/rsync, and remember that "may run rsync as root" is "may read and write any file as root".
rrsync: Restricted Keys
rrsync ships with rsync (/usr/bin/rrsync on Debian 13; a Python script, so it needs python3). It is a forced command that accepts only an rsync server invocation confined to one directory:
# ~/.ssh/authorized_keys on the server — one line per restricted key
command="rrsync -ro /srv/backups",restrict ssh-ed25519 AAAAC3Nz... puller@ci
command="rrsync -wo -no-del /srv/incoming",restrict ssh-ed25519 AAAAC3Nz... pusher@app1
| Option | Effect |
|---|---|
-ro |
Read-only: the key can only pull. Implies -no-del |
-wo |
Write-only: the key can only push |
-no-del |
Refuse --delete* and --remove-source-files |
-no-overwrite |
Force --ignore-existing (3.3.0+) |
-munge |
Force --munge-links so stored symlinks cannot point out of the directory |
The client addresses paths relative to the restricted directory: rsync -a host:pub/ ./pub/ reads /srv/backups/pub/. What refusals look like from the client:
/usr/bin/rrsync error: sending to read-only server is not allowed
/usr/bin/rrsync error: do not use .. in arg (anchor the path at the root of your restricted dir)
/usr/bin/rrsync error: SSH_ORIGINAL_COMMAND does not run rsync
The first two surface on the client as exit 12. rrsync rejects -s/--secluded-args (it could not inspect the options), so do not set RSYNC_PROTECT_ARGS on clients using restricted keys. From 3.5.0 a push through an rrsync key creates no devices, FIFOs, or sockets: the client prints skipping non-regular file "fifo" and still exits 0 (Debian's 3.5.0 rrsync passes --drop-D to the receiving rsync; upstream's notes call it forcing --no-D). A plain rrsync DIR key with neither -ro nor -no-del can still --delete everything in DIR. rrsync has had real escapes fixed upstream (see the version matrix) — keep it patched, and prefer -ro pull keys held by the backup server over push keys held by every client.
Daemon Mode
A double colon (host::module) or an rsync:// URL talks to an rsync daemon on TCP 873 instead of ssh. Modules are named exports defined in rsyncd.conf.
# /etc/rsyncd.conf
max connections = 8
log file = /var/log/rsyncd.log
[mirror]
path = /srv/mirror
comment = public read-only mirror
read only = yes
[upload]
path = /srv/incoming
comment = authenticated drop
read only = no
auth users = backup
secrets file = /etc/rsyncd.secrets
hosts allow = 10.0.4.0/24
/etc/rsyncd.secrets holds user:password lines. It must not be readable or writable by others (chmod 600) and, when the daemon runs as root, must be owned by root; otherwise the daemon ignores it and every login fails with auth failed (the log says secrets file must be owned by root when running as root). read only defaults to yes for every module. This is a root daemon's config: the lock file behind max connections, the log, and the secrets file all sit in root-only paths, and a writable module's path must be writable by the uid the daemon drops to (nobody by default).
rsync rsync://host/ # list modules
rsync -a rsync://host/mirror/ ./mirror/ # pull
rsync -a host::mirror/ ./mirror/ # same, double-colon form
rsync -a --password-file=pw src/ rsync://backup@host/upload/
rsync -a --port=8873 host::mirror/ ./mirror/ # non-default port (or rsync://host:8873/…)
$ rsync rsync://127.0.0.1:8873/
data read-only export
upload authenticated drop
$ rsync -a src/ rsync://127.0.0.1:8873/data/
ERROR: module is read only
- The daemon protocol is not encrypted — not the data and, beyond a challenge-response on the password, not the session. Use it for public mirrors or on networks you trust; otherwise front it with TLS (
rsync-sslon the client, a TLS proxy on the server) or run it over ssh. - On Debian the packaged
rsync.serviceonly starts if/etc/rsyncd.confexists. - Daemon features over ssh:
rsync -a -e ssh host::module/ dest/starts a single-use daemon on the far side that reads~/rsyncd.confthere (/etc/rsyncd.confif the remote user is root). You get modules and their restrictions with ssh's authentication and encryption, and no listening port.
Large and Interrupted Transfers
Key Concepts
By default an interrupted file is thrown away: the receiver deletes its temp file and the next run starts that file from zero. Everything in this section is about changing that, and each option trades away some of the temp-file-then-rename safety described above.
Keeping Partial Files
rsync -a --partial src/ host:/dst/ # keep the fragment under the REAL name
rsync -a --partial-dir=.rsync-partial src/ host:/dst/ # keep it out of the way (implies --partial)
rsync -aP src/ host:/dst/ # -P = --partial --progress
Interrupting a 40 MiB transfer after about 10 MiB, then looking at the destination:
(no option) dst/ empty — fragment deleted
--partial dst/big 10584064 bytes, under the final name
--partial-dir dst/.rsync-partial/big 10584064 bytes
Plain --partial leaves a truncated file where consumers expect a whole one. Prefer --partial-dir: the fragment is hidden, used as the basis on the next run, then removed. The resumed run above reported Matched data: 10,581,984 bytes. A plain --partial fragment is also left with a 1970 mtime (the epoch plus the source's sub-second part), where a --partial-dir fragment keeps a current one. A relative --partial-dir is automatically excluded from transfer and protected from --delete; never point it at a world-writable directory such as /tmp.
--append-verify
rsync -a --append-verify src/ host:/dst/ # resume by appending, then checksum the whole file
For files that only ever grow (logs, dumps in progress) or a --partial fragment: rsync sends only the bytes past the receiver's current length. Resuming the 10 MiB fragment sent Literal data: 31,358,976 bytes — just the remainder. --append-verify checksums the complete result; on a mismatch it warns failed verification -- update retained (will try again) (shown only with -v or -i) and re-sends the file normally in the same run. Plain --append does not check the existing part: given a receiver file whose prefix differed from the sender's, it appended, exited 0, and left a corrupt file. Do not use either on trees containing files that are rewritten rather than appended; files the same length or longer on the receiver are skipped outright.
--inplace and Its Hazards
--inplace writes directly into the destination file instead of a temp copy. It is the right tool for huge files with small changes (VM images, database files) on space-constrained or copy-on-write storage, where it avoids needing double the space and keeps snapshots from diverging. The costs:
- An interrupted transfer leaves a hybrid. Interrupting an in-place update of a 40 MiB file left a file of the full, correct size whose first 10 MiB was new and the rest old — nothing about its name or size says it is corrupt.
- Readers see the file mid-rewrite. No atomic switch.
- Hard links are written through. Every other link to that inode gets the new data — which wrecks a
--link-destsnapshot history. - In-use binaries cannot be replaced safely; delta efficiency can drop because blocks are overwritten before they can be reused.
It implies --partial and conflicts with --partial-dir and --delay-updates. --delay-updates is the opposite trade: every file is staged and all are renamed in one burst at the end, for trees that must flip close to atomically.
Bandwidth, Progress, and Timeouts
rsync -a --bwlimit=10m src/ host:/dst/ # 10 MiB/s
rsync -a --bwlimit=5000 src/ host:/dst/ # a bare number is KiB/s -> ~4.9 MiB/s
rsync -a --info=progress2 --no-inc-recursive src/ host:/dst/ # one whole-transfer progress line
rsync -a --timeout=300 src/ host:/dst/ # give up after 300s without I/O (exit 30)
--bwlimit units are 1024-based and the default unit is KiB per second, not bits; a 30 MiB file at --bwlimit=10m took 3.0 s. --info=progress2 (3.1.0+) shows totals rather than per-file lines; adding --no-inc-recursive makes rsync scan the whole tree first so the percentage means something:
41,943,040 100% 540.12MB/s 0:00:00 (xfr#1, to-chk=0/2)
Compression and Checksum Negotiation
Since 3.2.0 the two ends negotiate both the compression algorithm and the checksum, picking the first entry in the client's list that the server also has. Between two Debian 13 hosts:
$ rsync -az --debug=NSTR src/ host:/dst/
Client negotiated checksum: xxh128
Client negotiated compress: zstd (level 3)
rsync -az src/ host:/dst/ # negotiated: zstd where both ends have it
rsync -a --zc=lz4 src/ host:/dst/ # force an algorithm (--zc implies -z)
rsync -a --zc=zstd --zl=9 src/ host:/dst/ # ...and a level
rsync -a --cc=xxh3 src/ host:/dst/ # force the checksum
RSYNC_COMPRESS_LIST=zlib rsync -az src/ host:/dst/ # constrain negotiation instead of forcing
-zcompresses the wire, not the files. It pays on slow links and compressible data (200 KiB of zeros went from 204,961 bytes sent to 137), and costs CPU for nothing on a fast LAN or already-compressed data.- A pre-3.2 peer gets no negotiation: compression falls back to zlib, and the checksum to MD5 or — at protocol 29 and below — MD4 (
--protocol=29 --debug=NSTRon 3.5.0 reportsClient checksum: md4). That is every transfer involving stock macOS. --skip-compressis documented in 3.5.0 as currently having no effect.rsync --versionlists what a build supports (Checksum list:,Compress list:).
Snapshots and Backups
--link-dest: Hard-Linked Snapshots
--link-dest=DIR compares each source file against DIR; files that are unchanged in content and in every preserved attribute are hard-linked from DIR instead of being copied. Each snapshot directory is a complete, browsable tree, but only changed files consume new space.
rsync -a --mkpath /srv/data/ /backup/data/2026-10-01/
rsync -a --link-dest=../2026-10-01 /srv/data/ /backup/data/2026-10-02/
links inode path (one small file changed between runs)
2 8136 2026-10-01/big1
2 8137 2026-10-01/big2
1 8138 2026-10-01/small
2 8136 2026-10-02/big1 <- same inode, no extra space
2 8137 2026-10-02/big2
1 8140 2026-10-02/small <- changed, so a fresh copy
$ du -sh 2026-10-01 2026-10-02
2.1M 2026-10-01
4.0K 2026-10-02
du counts each inode once per invocation, so the second snapshot shows only its unique data; measured alone it reports the full 2.1M.
Traps:
- A relative
--link-destis relative to the destination directory, not your working directory.../2026-10-01is right;backup/data/2026-10-01is wrong. - A wrong path is only a warning. rsync prints
--link-dest arg does not exist: …, copies everything in full, and exits 0. An unattended job with a broken path quietly stores a full copy every night. - On 3.5.0 a
--link-destreached through a symlink owned by another user is not followed. rsync printsrefusing to follow a symlink owned by an untrusted user; use --insecure-links locally …, copies everything in full, and exits 0. Give the real path, or pass--insecure-linksif every component is trusted. - Any attribute difference defeats the link. After a bare
chmodon the source the file itemises ascf...p.....and is copied, not linked. A mount that squashes ownership means nothing ever links. - Copy into a new, empty directory each run, and never use
--inplaceon a snapshot tree. - Up to 20
--link-destdirectories may be given; they are searched in order.
A minimal rotation — build into a .partial name and rename, so an interrupted run never looks like a good snapshot:
#!/usr/bin/env bash
set -euo pipefail
SRC=/srv/data/
DEST=/backup/data
NOW=$(date +%F-%H%M)
mountpoint -q /backup || { echo "/backup not mounted" >&2; exit 1; }
LATEST=$(find "$DEST" -mindepth 1 -maxdepth 1 -type d -name '20*' ! -name '*.partial' | sort | tail -n 1)
rsync -aHAX --numeric-ids ${LATEST:+--link-dest="$LATEST"} "$SRC" "$DEST/$NOW.partial/"
mv "$DEST/$NOW.partial" "$DEST/$NOW"
Expire old snapshots with plain rm -rf; data survives until its last link is gone.
--backup and --backup-dir
rsync -a --delete --backup src/ dst/ # old versions kept as name~
rsync -a --delete --backup --backup-dir=../bak/2026-10-05 src/ dst/ # ...or moved to a dated tree
rsync -a --delete --backup --suffix=.orig src/ dst/ # change the suffix
Anything rsync would overwrite or delete is moved aside first. Without --backup-dir the ~ copies sit beside the live files (and are protected from --delete only on runs that also pass --backup — a later plain --delete removes them); with it they go to a parallel tree — a relative --backup-dir is relative to the destination. A cheap undo for a risky sync; not a retention scheme.
--compare-dest and --copy-dest
rsync -a --compare-dest=../full src/ delta/ # delta/ receives ONLY files that differ from ../full
rsync -a --copy-dest=../prev src/ new/ # like --link-dest, but unchanged files are copied locally
--compare-dest builds a sparse overlay of changes against a baseline (a patch tree to ship elsewhere). --copy-dest gives full independent copies without re-transferring unchanged data — for filesystems without hard links.
Ownership and Metadata Across Hosts
Key Concepts
By default rsync maps owners and groups by name: a file owned by www-data arrives owned by whatever uid www-data has on the receiver, falling back to the numeric id when the name does not exist there. Uid 0 and gid 0 are never remapped. All of this applies only when the receiver is root.
# Backups and anything restored to a different machine: keep raw numbers
rsync -aHAX --numeric-ids /srv/ host:/backup/srv/
# Force an owner on arrival (receiver must be root; 3.1.0+)
rsync -a --chown=www-data:www-data ./dist/ host:/srv/www/
# Remap specific identities
rsync -a --usermap=1000:deploy --groupmap='*:www-data' src/ host:/dst/
# Normalise permissions on the way in
rsync -a --chmod=D755,F644 ./dist/ host:/srv/www/
rsync -a --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r ./dist/ host:/srv/www/ # symbolic form
--numeric-idsis the right default for backups. Name mapping restores files to the wrong owner the moment uids differ between the host backed up and the host restored to — and inside a chroot or rescue environment there may be no names at all.--chownas a non-root receiver does nothing, silently (exit 0, files owned by you).--chmodrewrites the permissions the sender reports;DandFprefixes scope a clause to directories or files. Newly created files always get the result, but files that already exist on the receiver are only changed if-p(or-a) is in effect.
--fake-super: Root's Metadata Without Root
rsync -a -M--fake-super /etc/ backup@host:/backup/etc/ # store: remote side unprivileged
rsync -a -M--fake-super backup@host:/backup/etc/ /etc/ # restore: local side is real root
With --fake-super an unprivileged receiver stores the owner, group, special mode bits, and device numbers it is not allowed to apply in a user.rsync.%stat extended attribute; a --fake-super sender reads them back.
$ stat -c '%U:%G %a' backup/rootfile
mike:mike 644
$ python3 -c "import os; print(os.getxattr('backup/rootfile', 'user.rsync.%stat'))"
b'100644 0,0 0:0'
That is a root-owned (0:0) mode-644 file, stored as mike:mike on disk.
Files already owned by the storing user get no attribute. The option affects only the side it is given on — -M--fake-super sends it to the remote end, which is nearly always what you mean. The backing filesystem must support user xattrs. For a daemon, the equivalent is fake super = yes in the module.
Version Matrix
What Ships Where
| Platform | Binary | Version | Protocol |
|---|---|---|---|
| Debian 13 (trixie) | /usr/bin/rsync |
3.5.0 from trixie-security; 3.4.1 plus backported fixes until that update is applied |
32 |
| macOS (Sequoia 15.4 onwards) | /usr/bin/rsync |
openrsync, "rsync version 2.6.9 compatible" | 29 |
| Homebrew | $(brew --prefix)/bin/rsync |
3.5.1 | 33 |
| Upstream current | — | 3.5.1 (21 Sep 2026) | 33 |
$ /usr/bin/rsync --version # macOS
openrsync: protocol version 29
rsync version 2.6.9 compatible
$ rsync --version | head -1 # Debian 13
rsync version 3.5.0 protocol version 32
Two ends negotiate down to the lower protocol, so a Mac talking to a 3.5.0 server runs the whole session at protocol 29.
openrsync on macOS
openrsync is a BSD-licensed reimplementation, not a patched rsync, and it is not a drop-in replacement. Rejected by /usr/bin/rsync on macOS 27.0:
| Area | Missing flags |
|---|---|
| Metadata | -A/--acls, -X/--xattrs, --fake-super, --chown, --usermap, --groupmap, -U/--atimes, -N/--crtimes |
| Paths and args | --mkpath, -s/--secluded-args, --old-args, --iconv, --munge-links |
| Transfer control | --append-verify, --zc/--compress-choice, --zl, --cc/--checksum-choice, --skip-compress, --preallocate, --fsync, --stop-at, --stop-after |
| Output | --info=… (so no progress2), --debug=…, --stderr, --outbuf |
| Misc | -M/--remote-option, --delete-missing-args, --ignore-missing-args, --write-devices, --copy-as, --trust-sender, --max-alloc |
Accepted: -a -H -S -z -c -n -i -P -x -F -R -m, the whole --delete-* family with --max-delete, --link-dest/--compare-dest/--copy-dest, --partial-dir, --append, --inplace, --bwlimit, --files-from, --backup-dir, --numeric-ids, --rsync-path, --stats.
Same flag, different behaviour:
-Emeans extended attributes (and resource forks and ACLs) on macOS. On rsync,-Eis--executability. A script using-Edoes different things on each.--chmodtakes symbolic modes only:--chmod=F644,D755fails withinvalid argument;--chmod=Fu=rw,Fgo=rworks.- Itemised output is nine characters, not eleven.
- No algorithm negotiation. A 3.5.0 server confirms the session as
Protocol versions: remote=29, negotiated=29, and at protocol 29 rsync 3.5.0 uses zlib and MD4.
The fix is brew install rsync, then make sure Homebrew's bin precedes /usr/bin in PATH (check with type -a rsync). Cron, launchd jobs, and anything using an absolute /usr/bin/rsync still get openrsync. When a Mac is the remote end, select the real one with --rsync-path=/opt/homebrew/bin/rsync (Apple silicon prefix).
When Features Arrived
| Version | Date | What |
|---|---|---|
| 3.1.0 | Sep 2013 | Protocol 31. --info/--debug flags, --info=progress2, --chown/--usermap/--groupmap, --preallocate |
| 3.2.0 | Jun 2020 | zstd and lz4 compression, xxHash checksums, algorithm negotiation, --debug=NSTR, --zc/--cc, --atimes, --open-noatime, --write-devices, --copy-as |
| 3.2.2 | Jul 2020 | xxh3 / xxh128 (with xxhash 0.8), --max-alloc |
| 3.2.3 | Aug 2020 | --mkpath, --stop-at/--stop-after, --crtimes, --stderr |
| 3.2.4 | Apr 2022 | Remote args backslash-escaped by default (spaces in remote paths just work), --old-args, --fsync, rrsync rewritten in Python with -munge/-no-del/-no-lock |
| 3.2.5 | Aug 2022 | Receiver validates the sender's file list (CVE-2022-29154); --trust-sender |
| 3.2.6 | Sep 2022 | --protect-args renamed --secluded-args (-s unchanged) |
| 3.3.0 | Apr 2024 | rrsync -no-overwrite |
| 3.4.0 | 15 Jan 2025 | Security release, protocol 32 |
| 3.4.1 | 16 Jan 2025 | Fixes 3.4.0 regressions, including -H |
| 3.4.2 | Apr 2026 | Hardening; multi-threaded zstd via --compress-threads |
| 3.4.3 | May 2026 | Six further CVEs |
| 3.5.0 | Aug 2026 | Path-handling and daemon audit: 33 security fixes |
| 3.5.1 | Sep 2026 | Protocol 33; fixes 3.5.0 path-handling regressions |
The Security Releases
January 2025 — 3.4.0. Six CVEs: a heap buffer overflow in checksum parsing (CVE-2024-12084) and an info leak that defeats ASLR (CVE-2024-12085), which together threatened code execution on a server from a client with only anonymous read access; a server able to read arbitrary files from a connecting client (CVE-2024-12086); a server able to make a client write outside its destination via symlinks (CVE-2024-12087); a --safe-links bypass (CVE-2024-12088); and a symlink race (CVE-2024-12747). Upstream bumped the protocol number to 32 purely so administrators could tell patched servers apart.
2026 — 3.4.3 and 3.5.0. 3.4.3 fixed six more, three of them reachable only through non-default daemon configuration. 3.5.0 followed a focused audit and fixed 33: eleven symlink races where a local user redirects a privileged rsync, an rrsync restricted-directory escape, and the rest daemon protocol, access-control, and memory-safety issues. It also changed behaviour: a destination that is a symlink owned by another, non-root user is now refused with exit 3 — even when rsync runs as root — unless --insecure-links is given; 3.4.1 followed it.
Reading a version string correctly:
- Protocol 32 tells you the January 2025 batch is in. It tells you nothing about 2026. Debian 13 reported
3.4.1 … protocol 32while the 3.4.3 fixes were backported (3.4.1+ds1-5+deb13u4), and a fully patched one reports3.5.0 … protocol 32(3.5.0+ds1-0+deb13u1fromtrixie-security, carrying the 33 and six of the 3.5.1 regression fixes); the package revision (dpkg -l rsync) is what to check. - The exposure is largest where rsync runs with privilege against input someone else controls: public daemons, root cron jobs pulling from less-trusted hosts, rrsync keys. Patch those first.
- When pulling from a host you do not fully trust, copy into a dedicated directory, never straight into
~or/.
Exit Codes
Key Concepts
rsync's exit code is the only reliable signal in a script; the text on stderr varies by version. The codes worth branching on:
| Code | Meaning | Typical cause |
|---|---|---|
| 0 | Success | Includes "nothing to do" — and a faithful mirror of an empty source |
| 1 | Syntax or usage error | Unknown flag (often: openrsync), --delete without -r, push to a read-only daemon module |
| 2 | Protocol incompatibility | Remote shell printed something: is your shell clean? |
| 3 | Errors selecting input/output files, dirs | The destination cannot be entered: it is a file, not accessible, or (3.5.0+) a symlink owned by another non-root user |
| 5 | Error starting client-server protocol | Daemon: unknown module, auth failed |
| 10 | Error in socket I/O | Daemon unreachable; connection reset mid-stream (a clean close shows as 12); remote rsync killed during a push |
| 11 | Error in file I/O | Destination parent missing (use --mkpath); write failure on the receiver |
| 12 | Error in rsync protocol data stream | The pipe died: ssh could not connect or authenticate, remote rsync killed during a pull (a push reports 10), rrsync refused the command |
| 20 | Received SIGINT, SIGTERM, or SIGHUP | Interrupted or killed (SIGUSR1 is 19). Under a timeout wrapper the script sees timeout's own 124 unless --preserve-status is given |
| 23 | Partial transfer due to error | Some files failed (permissions, missing source argument); the rest were copied |
| 24 | Partial transfer due to vanished source files | Files deleted between scan and transfer; normal on live trees |
| 25 | --max-delete limit stopped deletions |
Guard rail fired |
| 30 | Timeout in data send/receive | --timeout expired |
| 35 | Timeout waiting for daemon connection | --contimeout expired |
| 127 | Remote command not found | rsync missing on the far side, or wrong --rsync-path |
| 255 | Unexplained error | The remote-shell command itself exited 255 |
12 versus 255. ssh exits 255 on its own failures, and rsync is often described as passing that through. It is a race: rsync's own verdict on a dead pipe is 12 (connection unexpectedly closed (0 bytes received so far)), and it substitutes the remote shell's exit status only if that is higher and the shell has already exited when rsync cleans up. Against rsync 3.5.0 with OpenSSH, twenty runs each of an unresolvable host and a rejected key gave exit 12 most of the time and 255 for the rest (17/3 and 16/4), while a refused port gave 12 every time (20/0); the same race decides whether a missing remote rsync shows as 127 or 12. openrsync on macOS returned 255 for the same unresolvable host. Treat both as "transport failed" and read the ssh error printed above rsync's own.
23 is not atomic failure. Everything that could be copied was; only the named files were skipped. With --delete it also triggers the I/O-error guard described earlier.
Scripting Against Them
rsync -a --delete --max-delete=500 --timeout=300 /srv/data/ backup:/backup/data/
rc=$?
case $rc in
0) ;; # clean
24) echo "warn: files vanished during transfer" >&2 ;;
23) echo "error: partial transfer" >&2; exit $rc ;;
25) echo "error: delete limit hit — source suspicious" >&2; exit $rc ;;
10|12|30|255) echo "error: transport failed ($rc), retry later" >&2; exit $rc ;;
*) echo "error: rsync exit $rc" >&2; exit $rc ;;
esac
Do not wrap rsync in a blanket || true to silence 24 — that also hides 23 and 25. Debian ships a ready-made wrapper for the purpose at /usr/share/rsync/scripts/rsync-no-vanished.
Quick Reference
Everyday Commands
| Task | Command |
|---|---|
| Mirror a directory (contents to contents) | rsync -a SRC/ DEST/ |
| Preview, itemised | rsync -ain SRC/ DEST/ |
| Mirror and prune, previewed | rsync -ain --delete SRC/ DEST/ |
| Mirror and prune with a cap | rsync -a --delete --max-delete=500 SRC/ DEST/ |
| Full-fidelity system copy | rsync -aHAXS --numeric-ids SRC/ DEST/ |
| Push over ssh on another port | rsync -a -e 'ssh -p 2222' SRC/ host:DEST/ |
| Through a bastion | rsync -a -e 'ssh -J bastion' SRC/ host:DEST/ |
| Remote side as root | rsync -a --rsync-path='sudo rsync' SRC/ host:DEST/ |
| Resumable large transfer | rsync -a --partial-dir=.rsync-partial --info=progress2 SRC/ host:DEST/ |
| Throttle to 10 MiB/s | rsync -a --bwlimit=10m SRC/ host:DEST/ |
| Compress on a slow link | rsync -az SRC/ host:DEST/ |
Only *.log, any depth |
rsync -am --include='*/' --include='*.log' --exclude='*' SRC/ DEST/ |
| Exclude from a file | rsync -a --exclude-from=excludes.txt SRC/ DEST/ |
| Copy a named list | rsync -a --files-from=list.txt SRC/ DEST/ |
| Hard-linked snapshot | rsync -a --link-dest=../PREV SRC/ DEST/NEW/ |
| Keep overwritten and deleted files | rsync -a --delete --backup --backup-dir=../bak/DATE SRC/ DEST/ |
| Compare by content | rsync -ainc SRC/ DEST/ |
| Create deep destination | rsync -a --mkpath SRC/ DEST/a/b/c/ |
| Move (copy, then remove source files) | rsync -a --remove-source-files SRC/ DEST/ |
| List daemon modules | rsync rsync://host/ |
| What did we negotiate? | rsync -az --debug=NSTR SRC/ host:DEST/ |
| Which filter rule fired? | rsync -an --debug=FILTER … SRC/ DEST/ |
--remove-source-files removes files only; the emptied source directories stay behind.
Flags
| Flag | Meaning |
|---|---|
-a |
-rlptgoD — recurse; keep symlinks, perms, times, group, owner, devices |
-H -A -X -S |
Hard links, ACLs, xattrs, sparse files — not in -a |
-n / -i |
Dry run / itemise changes |
-v / -q |
Verbose / quiet |
-z |
Compress in transit |
-c |
Select files by checksum, not size + mtime |
-u |
Skip files newer on the receiver |
-x |
Do not cross filesystem boundaries |
-P |
--partial --progress |
-R |
Preserve the source path as given (/./ marks where it starts) |
-m |
Prune empty directories from the transfer |
-W / --no-W |
Whole-file / force delta transfer |
-e CMD |
Remote shell command |
-F / -FF |
Per-directory .rsync-filter / also hide those files |
-M OPT |
Send an option to the remote side only |
-h |
Human-readable numbers |
Common Issues and Solutions
| Issue | Cause | Solution |
|---|---|---|
Destination ended up as dest/src/… |
No trailing slash on the source | rsync -a src/ dest/; move the nested copy up before re-running with --delete |
| Destination emptied, exit 0 | --delete from an empty or unmounted source |
Guard with mountpoint -q or a sentinel file, plus --max-delete; restore from the previous snapshot |
| Root disk filled during a backup | Destination filesystem was not mounted | Guard the destination mount as well; clear the files from underneath the mount point |
--delete removes nothing |
Source given as src/*, so no directory was synchronised |
Use src/ |
| Include rule has no effect | An earlier rule matched first, or a parent directory is excluded | Reorder; add --include='*/' or each parent explicitly; check with --debug=FILTER |
--exclude='/foo' does not match |
Anchor is relative to the transfer root, which moved with the trailing slash | Match the form of the source: /foo for src/, /src/foo for src |
| Every file re-transfers each run | No -t/-a, or the target filesystem rounds or drops mtimes |
Add -a; use --modify-window=1 on FAT/exFAT; --size-only as a last resort |
| A changed file was not copied | Same size and same whole-second mtime | Run once with -c; fix whatever resets mtimes |
| Files arrive owned by the login user | Receiver is not root, so -o/--chown are ignored |
--rsync-path='sudo rsync', or -M--fake-super |
| Owners wrong after a restore | Names mapped to different uids on the new host | Back up and restore with --numeric-ids |
| Hard links became separate files; sparse image ballooned | -a omits -H and -S |
-aHAXS |
mkdir "…" failed: No such file or directory, exit 11 |
More than the final destination component is missing | --mkpath (3.2.3+), or create the parent first |
protocol version mismatch -- is your shell clean?, exit 2 |
Remote shell startup files print to stdout | Make the output interactive-only; test with ssh host /bin/true | wc -c (must be 0) |
connection unexpectedly closed (0 bytes received so far), exit 12 |
ssh never delivered a working remote rsync | Read the ssh line above it; test ssh host rsync --version |
rsync: command not found / remote command not found, exit 127 |
rsync absent on the far side, or a bad --rsync-path |
Install it there; rsync is needed on both ends |
unknown option / unrecognized option, exit 1 |
Flag newer than the binary — typically openrsync on macOS | rsync --version; brew install rsync and fix PATH |
some files/attrs were not transferred, exit 23 |
Unreadable source files, or a missing source argument | Fix permissions or run the sender as root; the remainder did transfer |
file has vanished, exit 24 |
Source changed during the run | Expected on live data: accept 24, or snapshot the source first |
--link-dest snapshots are all full size |
Path wrong (only a warning), or attributes differ | Use a path relative to the destination or absolute; grep logs for --link-dest arg does not exist |
| Truncated file under its real name after an interruption | Plain --partial |
Use --partial-dir=.rsync-partial |
| File is the right size but corrupt after an interrupted run | --inplace leaves a hybrid of old and new |
Re-run to completion; avoid --inplace unless the consumer can tolerate it |
@ERROR: auth failed on module …, exit 5 |
Wrong password, or the secrets file is readable by others or (root daemon) not owned by root | chmod 600 and chown root the secrets file; check auth users |
ERROR: module is read only |
read only defaults to yes |
Set read only = no on that module, with auth users and hosts allow |
rrsync error: sending to read-only server is not allowed |
The key is restricted with -ro |
Use a separate -wo key for pushes |
| Transfer far slower than the link | -z on a fast LAN, -c on a large tree, or per-run ssh handshakes |
Drop -z/-c; try -W; enable ssh multiplexing |
Related Topics
The following topics complement this rsync cheatsheet:
- SSH & SSH Config — the transport underneath:
ProxyJump, connection multiplexing, keys and agents, and forced commands for restricted keys - rclone — the same job against object storage and cloud drives, where rsync has no far-side process to talk to
- Linux Storage & Filesystems — mounts and
fstab(the unmounted-source failure), filesystem snapshots, xattr and ACL support - Linux CLI —
find,tar, andcpfor the cases where rsync is not the right tool - systemd — timers and
OnFailure=for scheduling unattended sync jobs and acting on their exit codes - Bash —
set -euo pipefail,caseon exit codes, and safe handling of the variables that end up as rsync paths