## 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

```bash
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

```bash
-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

```bash
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

```bash
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

```bash
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

```bash
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

```bash
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

```bash
-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

```bash
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)

```bash
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.

```bash
#!/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:

- **`rsync` lands in `${RELEASE}/`, not in `current/`.** The webserver keeps serving the old release until the symlink moves. A half-finished rsync is invisible to traffic.
- **`ln -sfn` is a single `rename(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 the `current` symlink swaps atomically.

Rollback is the same trick in reverse:

```bash
# 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:

```bash
# 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:

```bash
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:

```bash
# 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:

1. **Always `--dry-run` first** on any new `--delete` command. The first column of `--itemize-changes` shows every deletion as `*deleting <path>` — read it before you trust it.
2. **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.
3. **Add a guardrail with `--max-delete=N`.** If rsync would delete more than `N` files, it aborts before touching anything. Set `N` to slightly more than the largest legitimate prune for that pipeline.
4. **Sync into a fresh release directory** (see workflow #1) and let `--delete` operate inside *that* directory, not inside `current/`. Worst-case blast radius is the release you were about to ship, not the one currently serving traffic.
5. **`--delete-excluded` is a separate, more aggressive flag.** It deletes destination files matching your `--exclude` patterns. Don't combine it with `--delete` unless 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:

```bash
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.

```bash
rsync -az \
  --partial --partial-dir=.rsync-partial \
  --append-verify \
  --info=progress2 \
  --timeout=300 \
  ./build/ "deploy@prod:${RELEASE}/"
```

What each flag buys:

- **`--partial`** keeps 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-partial`** stores 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 be `exec()`'d by a watcher process.
- **`--append-verify`** resumes a partial file by appending only the missing bytes — but first checksums the existing prefix against the source to catch corruption. Pure `--append` skips the check and is fast but unsafe; `--append-verify` is the right default.
- **`--info=progress2`** gives 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=300`** kills 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:

```bash
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?"

```bash
# 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.

```bash
# 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/ \| ssh host 'tar -xf - -C /dest/'` beats rsync on payloads with hundreds of thousands of small files. |
| `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 \| grep -A 30 "exit values"` for the full table; treat anything non-zero as "investigate before redeploying". |
| `--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](https://www.deployhq.com/features/zero-downtime-deployments), the build runs in [hosted build pipelines](https://www.deployhq.com/features/build-pipelines), the file transfer lands in a fresh release directory, the symlink swaps atomically, and [one-click rollback](https://www.deployhq.com/features/one-click-rollback) is one click away if the smoke test fails. The end-to-end setup for [deploying from GitHub](https://www.deployhq.com/deploy-from-github) walks through the SSH key and server configuration that this rsync workflow plugs into.

[Start a free DeployHQ trial](https://www.deployhq.com/signup) to put these patterns behind a webhook instead of a cron job.

---

## Related cheatsheets

- [SSH cheatsheet](https://www.deployhq.com/cheatsheets/ssh) — the transport rsync runs over; tune `~/.ssh/config` and your deploy keys here first.
- [Bash scripting cheatsheet](https://www.deployhq.com/cheatsheets/bash) — for the `set -euo pipefail` preamble and retry loops that wrap your rsync commands.
- [curl cheatsheet](https://www.deployhq.com/cheatsheets/curl) — for the post-rsync smoke tests that confirm the new release is actually serving traffic.
- [Docker cheatsheet](https://www.deployhq.com/cheatsheets/docker) — for the container builds that often replace rsync-based deploys on the way to immutable infrastructure.
- [Cron and crontab cheatsheet](https://www.deployhq.com/cheatsheets/cron) — for scheduling the rsync-based backups and offsite mirrors your deploys depend on.
- [Cheatsheets hub](https://www.deployhq.com/cheatsheets) — every DeployHQ cheatsheet in one place.

---

Need help? Email [support@deployhq.com](mailto:support@deployhq.com) or follow [@deployhq on X](https://x.com/deployhq).