rsync
What rsync is
rsync transfers files efficiently by only copying what has changed. It compares source and destination using file sizes and modification times (or checksums with -c), and only transmits the differences. This makes it much faster than cp or scp for repeated transfers of large directories.
rsync can transfer locally (same machine), over SSH (most common for sysadmin use), or via the rsync daemon protocol.
Basic usage
# Copy a file locally
rsync source.txt /destination/
# Copy a directory locally
rsync -r /source/dir/ /destination/dir/
# The typical production invocation (archive + verbose + progress)
rsync -avz /source/ /destination/
Common flags
-a, --archive # Archive mode: preserves permissions, timestamps, symlinks,
# owner, group, and recurses into directories.
# Equivalent to: -rlptgoD
-v, --verbose # Show files being transferred
-z, --compress # Compress data during transfer (good for slow links)
-r, --recursive # Recurse into directories (included in -a)
-p, --perms # Preserve file permissions (included in -a)
-t, --times # Preserve modification times (included in -a)
-o, --owner # Preserve file owner (included in -a, requires root on dest)
-g, --group # Preserve group (included in -a)
-l, --links # Preserve symlinks (included in -a)
-h, --human-readable # Print sizes in human-readable format (K, M, G)
--progress # Show per-file transfer progress
-n, --dry-run # Show what would be transferred without doing it
--stats # Print transfer statistics summary
-c, --checksum # Use checksums instead of size+mtime to decide what to copy
-avz is the standard starting point: archive mode preserves everything, verbose shows activity, compress helps over slow or remote links. Drop -z for local transfers (compression adds CPU overhead for no benefit).
The trailing slash rule
This is the most common rsync confusion. The trailing slash on the source controls whether the directory itself is copied or just its contents.
# WITHOUT trailing slash on source — copies the directory itself
rsync -av /data/web /backup/
# Result: /backup/web/index.html (directory "web" appears in destination)
# WITH trailing slash on source — copies the directory's CONTENTS
rsync -av /data/web/ /backup/web/
# Result: /backup/web/index.html (contents placed directly in destination)
# Trailing slash on destination has no special meaning
--dry-run first.
Over SSH
# Copy local directory to remote
rsync -avz /local/dir/ user@remote:/remote/dir/
# Copy from remote to local
rsync -avz user@remote:/remote/dir/ /local/dir/
# Specify a non-default SSH port
rsync -avz -e "ssh -p 2222" /local/dir/ user@remote:/remote/dir/
# Use a specific SSH key
rsync -avz -e "ssh -i ~/.ssh/deploy_key" /local/dir/ user@remote:/remote/dir/
# SSH with strict host key checking disabled (test environments only)
rsync -avz -e "ssh -o StrictHostKeyChecking=no" /local/ user@remote:/dest/
Dry run
# See exactly what would change, without changing anything
rsync -avz --dry-run /source/ /destination/
rsync -avz -n /source/ /destination/ # same thing
# Dry run with --delete to preview deletions
rsync -avz --delete --dry-run /source/ /destination/
--delete or when syncing to production. Read the output before pressing Enter again without -n.
Excluding files
# Exclude a single file or pattern
rsync -avz --exclude="*.log" /source/ /destination/
rsync -avz --exclude=".git" /source/ /destination/
# Exclude multiple patterns
rsync -avz \
--exclude="*.log" \
--exclude="*.tmp" \
--exclude=".git" \
--exclude="node_modules/" \
/source/ /destination/
# Use an exclude file (one pattern per line)
rsync -avz --exclude-from="/etc/rsync-excludes.txt" /source/ /destination/
# Example exclude file content:
# .git/
# node_modules/
# *.log
# *.tmp
# __pycache__/
# .env
--delete
--delete removes files from the destination that no longer exist in the source. This is what makes rsync a true mirror operation. Without it, old files accumulate at the destination.
# Sync and remove stale files at destination (mirror)
rsync -avz --delete /source/ /destination/
# Delete during transfer (default — most efficient)
rsync -avz --delete /source/ dest/
# Delete before transfer (safer if destination is full)
rsync -avz --delete-before /source/ dest/
# Safer: move deleted files to a trash dir instead of removing
rsync -avz --delete --backup --backup-dir=/tmp/rsync-deleted /source/ dest/
--dry-run to preview before running for real. Use --backup if you are unsure.
Common patterns
Nightly backup to a remote server
#!/usr/bin/env bash
set -euo pipefail
DATE=$(date +%Y-%m-%d)
rsync -avz --delete \
--exclude="*.tmp" \
--log-file="/var/log/rsync-backup-${DATE}.log" \
/data/production/ \
backup@backup01.example.com:/backups/production/
Incremental backup with hard links (time-machine style)
# Each run creates a new dated snapshot; unchanged files are hard-linked
# from the previous snapshot (no extra disk space for unchanged files)
DEST=/backups
LATEST="$DEST/latest"
NEW="$DEST/$(date +%Y-%m-%d_%H%M)"
rsync -avz --delete \
--link-dest="$LATEST" \
/data/production/ \
"$NEW/"
# Update the "latest" symlink
ln -sfn "$NEW" "$LATEST"
Deploy a website
# Deploy built files to web server, excluding source files
rsync -avz --delete \
--exclude=".git" \
--exclude="node_modules/" \
--exclude="src/" \
./dist/ \
deploy@web01:/var/www/html/
Synchronise config across servers
# Push config from a golden host to all web servers
for host in web01 web02 web03; do
echo "Syncing config to $host..."
rsync -avz /etc/nginx/ "root@${host}:/etc/nginx/"
ssh "root@${host}" "nginx -t && systemctl reload nginx"
done
Ansible
---
# ansible.builtin.synchronize wraps rsync for Ansible use
- name: Deploy application files
ansible.builtin.synchronize:
src: "{{ playbook_dir }}/files/app/"
dest: /opt/myapp/
delete: true
rsync_opts:
- "--exclude=.git"
- "--exclude=*.log"
# For simple file copies, ansible.builtin.copy is often better
# synchronize shines when you need --delete or complex exclude patterns
Troubleshooting
# Permission denied on destination
# — Check the remote user has write access to the destination directory
# rsync: command not found on remote host
# — rsync must be installed on BOTH local and remote hosts
ssh remote "which rsync"
dnf install rsync # RHEL
apt install rsync # Debian
# Files not being updated even though source changed
# — rsync uses mtime + size by default; if both match, file is skipped
# — Force checksum comparison:
rsync -avzc /source/ /destination/
# "Argument list too long" error
# — Too many files for a single rsync call
# — Split into subdirectories or use --files-from
# Slow transfer
# — -z adds compression CPU overhead on fast local/LAN links
# — Try without -z for local or gigabit transfers
rsync -av /source/ /destination/
# Check what rsync is doing (verbosely)
rsync -avvv /source/ /destination/ # triple -v for maximum verbosity
--itemize-changes (-i)
--itemize-changes (or -i) outputs a line per file showing exactly why rsync did or did not transfer it. This is the standard answer to "why didn't rsync copy this file?"
# Show per-file change summary
rsync -av --itemize-changes /source/ /destination/
# Dry-run with itemize to understand what would change
rsync -avn --itemize-changes /source/ /destination/
The output format is a string of 11 characters followed by the filename:
f.sT...... path/to/file.conf
^ f = regular file (d = dir, L = symlink)
^ . = not updated (< = sent to remote, > = received, c = created)
^ . = checksum same (c = different)
^ s = size differs
^ T = modification time differs
^^^^^ other attributes (permissions, owner, group, acl, xattr)
# Common output examples:
# >f+++++++++ newfile.conf → new file being sent
# >f.sT...... changed.conf → size and mtime differ, will be synced
# .f......... unchanged.conf → no changes (will not be transferred)
# cd+++++++++ newdir/ → new directory being created
--itemize-changes combined with --dry-run is the fastest debugging tool for rsync. If a file shows .f......... (no changes detected) but you expected it to be copied, the source and destination have identical size and mtime — use -c (checksum) to force a content comparison.
--partial and --partial-dir
For large file transfers over unreliable connections, --partial tells rsync to keep partially transferred files instead of deleting them. The next run resumes from where it left off.
# Keep partial files on interruption (resumes on next run)
rsync -av --partial /source/bigfile.tar.gz remote:/backups/
# Store partials in a dedicated directory (cleaner — won't confuse readers)
rsync -av --partial-dir=/tmp/.rsync-partial /source/ remote:/backups/
# Combine with --progress for large transfers
rsync -av --partial --progress /source/bigfile.tar.gz remote:/backups/
# --partial-dir is usually preferred for directories
rsync -av --partial-dir=/tmp/.rsync-partial /source/ remote:/destination/
Without --partial, an interrupted transfer leaves no trace — the next run starts the file from scratch. With --partial, the partial file is kept and rsync resumes using its block-level algorithm, transferring only the remaining data.
--numeric-ids
By default, rsync maps UID/GID numbers to names and transfers ownership by name. On different systems with different UID/GID assignments, this can cause ownership to be wrong after the transfer. --numeric-ids disables name mapping and transfers raw UID/GID numbers.
# Preserve exact UID/GID numbers (no name mapping)
rsync -av --numeric-ids /source/ /destination/
# Common use case: migrating data between servers with different /etc/passwd
# Without --numeric-ids: user 1001 "alice" on source maps to whatever uid 1001 is on dest
# With --numeric-ids: uid 1001 is preserved exactly, regardless of what user owns it on dest
# Combine with -a (which implies -o -g for owner/group preservation)
rsync -av --numeric-ids /data/ backup-server:/data/
When you need this:
- Migrating a web root or application data between servers where UIDs may differ
- Transferring
/homedirectories to a new server - Syncing files for a containerized service where UIDs are fixed by container spec
- Any migration where exact ownership must survive the transfer
Requires root (or appropriate capabilities) on both sides to set ownership. If running as a non-root user, -o -g (ownership/group) flags are silently ignored even with -a.