rsync Cheatsheet
What it is
rsync is a file-sync tool that copies only the differences between source and destination — the parts of files that actually changed, not the whole files. For deployment workflows that means shipping a 200 MB release tree across a flaky link in seconds instead of minutes, because only the deltas move on the wire.
This sheet covers the flags and patterns you reach for when pushing releases to production, plus the safety rails (--dry-run, --delete-after, --max-delete) that turn rsync from a useful copy tool into something you can trust to run unattended from a deploy pipeline.
Quick reference
Basic sync
rsync source/ dest/ # local → local
rsync -av source/ dest/ # archive mode + verbose (the everyday default)
rsync -avz source/ user@host:/dest/ # remote, with compression on the wire
rsync -avzP source/ user@host:/dest/ # --partial + --progress (interactive deploys)
The trailing slash on the source matters. source/ copies the contents of source into dest/. source (no slash) copies the source directory itself into dest/. This is the single most common one-line gotcha — see Common errors below.
The flags you'll actually use
-a, --archive # equals -rlptgoD: recursive, symlinks, perms, times, group, owner, devices
-v, --verbose # one v lists files; -vv adds reasons; -vvv adds debug
-z, --compress # gzip on the wire — worth it on slow links, often a wash on fast LANs
-P # shorthand for --partial --progress
-n, --dry-run # show what would change, change nothing — ALWAYS run this first
-h, --human-readable
--info=progress2 # overall progress line (better than -P for scripted output)
--stats # transfer summary at the end (bytes, files, time)
-i, --itemize-changes # one line per changed file with a YXcstpoguax flag string
-a is the default starting point for almost every deploy command. It preserves the permissions and timestamps that decide whether the app on the other side actually runs — without -a, executables land mode 0644 and your post-deploy hook fails with Permission denied.
rsync over SSH
rsync -av -e ssh source/ user@host:/dest/ # explicit SSH transport
rsync -av -e "ssh -i ~/.ssh/deploy_ed25519 -p 2222" source/ user@host:/dest/
rsync -av --rsync-path="sudo rsync" source/ user@host:/dest/ # run rsync as root on the remote
-e ssh is the default on modern rsync (3.x), so most invocations omit it. Reach for -e "ssh ..." when you need a non-default key, port, or ~/.ssh/config Host alias. Get the transport layer locked down — keys, ~/.ssh/config aliases, agent forwarding — before you start syncing; the Related cheatsheets section below links the full SSH playbook.
Exclude and include
rsync -av --exclude='.git' --exclude='node_modules' source/ dest/
rsync -av --exclude='*.log' --exclude='tmp/' source/ dest/
rsync -av --exclude-from=.deployignore source/ dest/ # newline-separated patterns
rsync -av --include='*.css' --exclude='*' source/ dest/ # whitelist (include before exclude)
.deployignore (newline-separated patterns) is what you want in version control. One file documents what's deploy-irrelevant, and every CI runner picks it up automatically.
Deletion modes
rsync -av --delete source/ dest/ # delete files in dest that aren't in source
rsync -av --delete-after source/ dest/ # delete AFTER transfer (safer ordering)
rsync -av --delete --max-delete=50 source/ dest/ # bail out if it would delete >50 files
rsync -av --delete --dry-run source/ dest/ # PREVIEW the deletions first
--delete is irreversible. Read the Deployment workflows section before adding it to a production pipeline.
Restartable / resumable transfers
rsync -av --partial --append-verify --info=progress2 source/ user@host:/dest/
rsync -av --partial-dir=.rsync-partial source/ user@host:/dest/
rsync -av --timeout=120 source/ user@host:/dest/ # die after 2 min of no I/O
--partial keeps half-transferred files on disk so the next run picks up where it left off. --append-verify resumes them with a checksum of the resumed prefix to catch corruption. Critical for production deploys over flaky links.
Bandwidth and CPU caps
rsync -av --bwlimit=10240 source/ user@host:/dest/ # 10 MB/s on the wire (KB/s units)
rsync -av --bwlimit=2M source/ user@host:/dest/ # same, expressed in MB/s (rsync 3.2+)
rsync -av --compress-level=1 source/ user@host:/dest/ # cheaper compression, less CPU on the runner
Preserve options
-p, --perms # preserve permissions (-a includes this)
-t, --times # preserve modification times (-a includes this)
-o, --owner # preserve owner (needs --rsync-path="sudo rsync" or being root remotely)
-g, --group # preserve group
-l, --links # copy symlinks as symlinks (-a includes this)
-L, --copy-links # copy what symlinks POINT TO instead — rarely what you want
-H, --hard-links # preserve hard links (separate; NOT in -a)
--omit-dir-times # don't try to set mtimes on directories (NFS-mounted destinations)
--no-times # don't preserve times at all (filesystems that reject futimens())
Inspection
rsync -avn --itemize-changes source/ dest/ # what WOULD change, no transfer
rsync -avn --delete source/ dest/ # what WOULD be deleted
rsync --stats -av source/ dest/ # post-run summary
rsync --version # rsync 3.2.x ships --mkpath, --copy-devices, etc.
The first column of --itemize-changes output uses a flag string like >f+++++++++ (new file), <f.st...... (file pulled, size + time changed), or *deleting (delete pending). Worth memorising — it's the only way to read --dry-run output at a glance.
Daemon mode (rare in deploys)
rsync rsync://mirror.example.com/module/path/ ./ # connect to an rsyncd
rsync -av --port=873 source/ rsync://host/module/ # explicit port
You'll see this on public Linux mirrors and CDN origins. Production deploys almost always use rsync-over-SSH instead — same wire format, but authenticated and encrypted by the SSH transport rather than a separate rsyncd config.
Deployment workflows (the moat)
1. Atomic releases via a symlinked current/
The pattern every Capistrano-style deploy uses, distilled to two commands. Sync into a fresh release directory, then flip a single symlink — the web server sees the new release the moment the symlink swaps, with no half-written state.
#!/usr/bin/env bash
set -euo pipefail
SHA=$(git rev-parse --short HEAD)
RELEASE="/var/www/releases/$(date -u +%Y%m%dT%H%M%SZ)-${SHA}"
# 1. Push the new release into its own directory (NOT into current/)
rsync -az --delete \
--exclude-from=.deployignore \
./build/ "deploy@prod:${RELEASE}/"
# 2. On the remote, flip the symlink in one operation
ssh deploy@prod "ln -sfn '${RELEASE}' /var/www/current && \
sudo systemctl reload nginx"
Two properties make this safe:
rsynclands in${RELEASE}/, not incurrent/. The webserver keeps serving the old release until the symlink moves. A half-finished rsync is invisible to traffic.ln -sfnis a singlerename(2)syscall. Readers (nginx, php-fpm) either see the old target or the new one, never an in-between state. This is the same atomic-release primitive that powers the DeployHQ zero-downtime pipeline linked in the Companion section below — each deploy lands in its own directory, and thecurrentsymlink swaps atomically.
Rollback is the same trick in reverse:
# Re-point current/ at the previous release (instant)
ssh deploy@prod "ls -1dt /var/www/releases/*/ | sed -n '2p' | \
xargs -I{} ln -sfn '{}' /var/www/current && \
sudo systemctl reload nginx"
Prune old releases on a schedule so disk doesn't fill:
# Keep the 5 most recent releases on the remote
ssh deploy@prod "cd /var/www/releases && \
ls -1dt */ | tail -n +6 | xargs -r rm -rf"
2. Exclude patterns for build artifacts
Every deploy should ship the output of your build, not the inputs and detritus around it. Without an exclude list, rsync happily copies .git/ (50–500 MB on long-lived repos), node_modules/ (often larger than the actual app), local .env files (a security incident waiting to happen), and a megabyte of .DS_Store files macOS scatters everywhere.
# .deployignore — checked into the repo
.git/
.github/
.gitignore
.gitattributes
# Local environment
.env
.env.*
!.env.production # explicitly include the prod template
# Build inputs (already compiled into dist/)
src/
node_modules/
vendor/bundle/cache/
# IDE / OS noise
.idea/
.vscode/
.DS_Store
Thumbs.db
# Logs and caches
*.log
tmp/
log/
storage/framework/cache/data/
.next/cache/
Patterns are matched relative to the source root. A leading / anchors to the root (/tmp/ matches only top-level tmp/); no leading slash matches anywhere in the tree. A trailing / matches directories only. !pattern re-includes something that an earlier pattern excluded — useful for "exclude everything in dist/ except dist/assets/":
dist/*
!dist/assets/
!dist/assets/**
The deploy command stays one flag richer:
rsync -az --delete \
--exclude-from=.deployignore \
./ "deploy@prod:${RELEASE}/"
For multi-stage builds where the deploy artifact is built fresh on the runner, exclude node_modules from rsync entirely and run npm ci --omit=dev inside the release directory after the symlink swap. You skip transferring 300 MB of node_modules, and the remote installs exactly what the lockfile says.
3. --delete safety
--delete tells rsync to remove files in the destination that don't exist in the source. It's how you guarantee the deployed tree matches the build output exactly — no orphan files from a previous release confusing the autoloader, no stale assets shadowing the new ones.
It's also how people accidentally wipe production. The classic failure modes:
# WRONG — missing trailing slash on source. This nests `release` INSIDE current/,
# then --delete wipes everything currently in current/ that doesn't match.
rsync -av --delete ./release prod:/var/www/current/
# WRONG — wrong source path entirely. Pointing at an empty directory with --delete
# is a one-line outage: the destination becomes that empty directory.
rsync -av --delete /tmp/build-that-failed/ prod:/var/www/current/
# WRONG — running --delete against the live current/ directory.
# A half-finished transfer leaves a half-empty current/ visible to traffic.
rsync -av --delete ./build/ prod:/var/www/current/
Five rules that prevent the incident:
- Always
--dry-runfirst on any new--deletecommand. The first column of--itemize-changesshows every deletion as*deleting <path>— read it before you trust it. - Use
--delete-after, not--delete(which is--delete-during). With--delete-after, files are deleted only after the transfer succeeds. A killed rsync leaves the destination in the pre-delete state instead of half-deleted. - Add a guardrail with
--max-delete=N. If rsync would delete more thanNfiles, it aborts before touching anything. SetNto slightly more than the largest legitimate prune for that pipeline. - Sync into a fresh release directory (see workflow #1) and let
--deleteoperate inside that directory, not insidecurrent/. Worst-case blast radius is the release you were about to ship, not the one currently serving traffic. --delete-excludedis a separate, more aggressive flag. It deletes destination files matching your--excludepatterns. Don't combine it with--deleteunless you've reasoned through the interaction — it's a frequent cause of "but I excluded that file, why is it gone?"
The deploy-safe form combining these:
rsync -az --delete-after --max-delete=200 \
--exclude-from=.deployignore \
./build/ "deploy@prod:${RELEASE}/"
If --max-delete=200 ever trips, the deploy fails with exit code 25. That's a feature, not a bug — a deploy that would have deleted 5,000 files is almost certainly the wrong source path.
4. Restartable transfers over flaky networks
Production deploys cross VPNs, congested transatlantic links, and the occasional carrier outage. Without --partial, a transfer interrupted at 95% throws away 95% of the work and starts over.
rsync -az \
--partial --partial-dir=.rsync-partial \
--append-verify \
--info=progress2 \
--timeout=300 \
./build/ "deploy@prod:${RELEASE}/"
What each flag buys:
--partialkeeps the partial file on the destination if rsync is killed mid-transfer. The next run resumes the file from where it stopped instead of restarting.--partial-dir=.rsync-partialstores partials in a hidden subdirectory rather than at the destination path. The destination filename only appears once the file is complete, so a partially-transferred binary never has a chance to beexec()'d by a watcher process.--append-verifyresumes a partial file by appending only the missing bytes — but first checksums the existing prefix against the source to catch corruption. Pure--appendskips the check and is fast but unsafe;--append-verifyis the right default.--info=progress2gives you a single rolling progress line instead of one-per-file output. Easier to read in CI logs, and won't bury an error 10,000 lines deep.--timeout=300kills rsync if there's no I/O for 5 minutes. Without it, a dead TCP connection holds the pipeline open indefinitely until the CI runner times out.
A wrapper that retries the transfer with backoff turns this into a "ships even when the link is bad" pattern:
for attempt in 1 2 3 4 5; do
if rsync -az --partial --partial-dir=.rsync-partial --append-verify \
--timeout=120 --info=progress2 \
./build/ "deploy@prod:${RELEASE}/"; then
break
fi
echo "rsync attempt ${attempt} failed; retrying in $((attempt * 10))s" >&2
sleep $((attempt * 10))
done
The combination of --partial and an idempotent retry loop is what makes long-haul deploys finish — each retry only moves the bytes that didn't make it last time. Pair it with the set -euo pipefail preamble (see the Bash scripting cheatsheet in Related cheatsheets) so the script actually exits when all retries fail instead of silently masking the failure.
5. Bandwidth caps for production deploys
Saturating a production server's NIC with a deploy transfer is a self-inflicted incident. App response times spike, healthchecks fail, the load balancer drops a node from rotation, and the next deploy step asks "why is the app down?"
# Cap at 10 MB/s during business hours
rsync -az --bwlimit=10240 \
--exclude-from=.deployignore \
./build/ "deploy@prod:${RELEASE}/"
--bwlimit is in KB/s (1024 = 1 MB/s) and applies to the rsync wire payload, not the underlying SSH framing. A 1 Gbps link comfortably runs deploys at 100–200 Mbps without affecting app traffic; cap at 25–50% of available bandwidth on shared links. The exact number matters less than picking one — uncapped rsync over a busy link is the failure mode.
For pipelines that deploy multiple servers in parallel, divide the cap by the parallelism so the aggregate transfer stays under the limit. Five servers in parallel with --bwlimit=10240 each is 50 MB/s leaving the runner — same problem you were trying to avoid, in a different direction.
6. Comparing two release directories
After a deploy, you sometimes need to know exactly what changed between the previous release and the new one — for an incident postmortem, a partial rollback decision, or just to sanity-check that the build output matches expectations.
# Dry-run + itemize-changes = a full diff manifest, no bytes moved
rsync -avn --delete --itemize-changes \
/var/www/releases/20260511T140000-abc123/ \
/var/www/releases/20260511T150000-def456/
# Same, but only printing the changes (no per-directory "/" entries)
rsync -avn --delete --itemize-changes \
/var/www/releases/20260511T140000-abc123/ \
/var/www/releases/20260511T150000-def456/ \
| grep -v '^\.d'
The >f+++++++++ / *deleting flag strings tell you, file by file, what would need to happen to turn release A into release B. diff -r on two large trees takes minutes and re-reads every byte; this rsync invocation streams through inode metadata first and only checksums files whose size or mtime differ, so it finishes in seconds even on 50,000-file trees.
Common errors and fixes
| Error / symptom | Cause | Fix |
|---|---|---|
Files end up in dest/source/ instead of dest/ |
Missing trailing slash on the source path | rsync -av source/ dest/ copies contents; rsync -av source dest/ copies the directory itself. Add the slash. |
rsync error: some files/attrs were not transferred (see previous errors) (code 23) |
Permission errors on individual files; the rest of the transfer succeeded | Re-read the verbose output for Permission denied lines. Either run with --rsync-path="sudo rsync", or chmod the offending files. Exit 23 is partial success. |
rsync: failed to set times on "...": Operation not permitted (1) |
Destination filesystem (NFS, FAT, some FUSE mounts) rejects futimens() for non-owners |
Add --omit-dir-times (just directories) or --no-times (everything). The data still copies; only mtimes are skipped. |
Permission denied (publickey) |
SSH authentication failed before rsync even started | rsync just calls ssh. Run ssh -i ~/.ssh/deploy_ed25519 user@host directly to debug the key, then put the same key in -e "ssh -i ...". The Related cheatsheets section below links the full SSH troubleshooting playbook. |
Connection reset by peer mid-transfer |
Flaky network, firewall idle timeout, or a sender-side OOM kill |
Add --partial --append-verify so the next run resumes. If it's a firewall, add -e "ssh -o ServerAliveInterval=30" to keep the connection warm. |
rsync: connection unexpectedly closed (0 bytes received so far) |
Remote rsync isn't installed, or its path isn't in the deploy user's PATH |
Confirm: ssh user@host 'which rsync'. If missing, install it; if installed but not in PATH, pass --rsync-path=/usr/bin/rsync. |
protocol version mismatch -- is your shell clean? |
Login scripts (.bashrc, .profile) on the remote are writing to stdout, corrupting the rsync protocol stream |
ssh user@host and watch for any output before the shell prompt — echo "welcome", motd scripts, etc. Move them into if [ -t 1 ]; then ... fi so they only run on interactive shells. |
--delete accidentally wiped the target |
Wrong source path, missing trailing slash, or --delete against current/ instead of a fresh release directory |
Recovery: cp -a /var/www/releases/<previous>/ /var/www/current/ if you still have a previous release on disk, or restore from backup. Prevention: see Deployment workflow #3 — always --dry-run, prefer --delete-after, add --max-delete, sync into a release directory not into current/. |
| Transfer is slow despite a fast link | Compression burning CPU on already-compressed data; or many tiny files spending more time in syscalls than on the wire | Drop -z on LAN transfers and on .tar.gz / .zip payloads (they don't compress further). For many tiny files, tar them up first: `tar -cf - build/ \ |
rsync: failed to exec ssh: No such file or directory |
rsync can't find ssh in its PATH (cron jobs especially) |
Pass the full path: -e "/usr/bin/ssh -i ...". Or set PATH at the top of the script. |
| Trailing-slash confusion: same command behaves differently on dev vs CI | On dev the path was ./build/, on CI it ended up as ./build (a Make variable, an env interpolation) |
Always quote: rsync -av "./build/" "user@host:${RELEASE}/". Test the expanded command with echo before running it. |
| Exit code 11 / 12 / 23 / 24 confusion | Each is a different partial-failure mode | 11: IO error on file; 12: protocol error; 23: some files not transferred (permissions); 24: source vanished during transfer. Check `rsync --help \ |
--exclude doesn't seem to do anything |
Pattern syntax — --exclude=node_modules matches node_modules anywhere; --exclude=/node_modules only at the root |
Use -avvn --exclude-from=.deployignore source/ dest/ and read the "[recv_files] skipping ..." lines. Patterns are not regexes; they're rsync's own glob dialect. |
Companion: full DeployHQ deploy workflow
rsync is the workhorse for file-based deploys, but the surrounding pipeline — webhook from your Git host, build step, healthcheck after the symlink swap, automatic rollback when something exits non-zero — is what makes it a deploy system rather than a copy command.
DeployHQ wires this together so you don't have to script the orchestration yourself: a push triggers the zero-downtime release pipeline, the build runs in hosted build pipelines, the file transfer lands in a fresh release directory, the symlink swaps atomically, and one-click rollback is one click away if the smoke test fails. The end-to-end setup for deploying from GitHub walks through the SSH key and server configuration that this rsync workflow plugs into.
Start a free DeployHQ trial to put these patterns behind a webhook instead of a cron job.
Related cheatsheets
- SSH cheatsheet — the transport rsync runs over; tune
~/.ssh/configand your deploy keys here first. - Bash scripting cheatsheet — for the
set -euo pipefailpreamble and retry loops that wrap your rsync commands. - curl cheatsheet — for the post-rsync smoke tests that confirm the new release is actually serving traffic.
- Docker cheatsheet — for the container builds that often replace rsync-based deploys on the way to immutable infrastructure.
- Cron and crontab cheatsheet — for scheduling the rsync-based backups and offsite mirrors your deploys depend on.
- Cheatsheets hub — every DeployHQ cheatsheet in one place.
Need help? Email support@deployhq.com or follow @deployhq on X.