rsync - Street Ops¶
What experienced operators know about rsync that the man page buries in 4000 lines of options.
Quick Diagnosis Commands¶
Checking What Would Change (Dry Run)¶
# Preview all changes, including deletions
rsync -avn --delete --itemize-changes source/ dest/
# Same, but only show files that differ
rsync -avn --delete --itemize-changes source/ dest/ | grep -v '^\.'
# Count how many files would transfer
rsync -avn source/ dest/ | grep -c '^'
# Show only files that would be deleted
rsync -avn --delete source/ dest/ | grep '^deleting'
The --itemize-changes output is your best friend for understanding exactly what rsync plans to do:
>f..t...... config.yml # file, timestamp changed
>f.s....... app.jar # file, size changed
>f..tp..... deploy.sh # file, timestamp + permissions changed
*deleting old-release/ # directory will be removed
cd+++++++++ new-feature/ # new directory being created
>f+++++++++ new-feature/app # new file being created
Verifying Sync Completeness¶
# After sync, verify source and dest match
rsync -avnc source/ dest/
# -c forces checksum comparison
# If output is empty, they match
# Compare file counts
find source/ -type f | wc -l
find dest/ -type f | wc -l
# Compare total sizes
du -sh source/ dest/
# Deep verification: generate checksums on both sides
find source/ -type f -exec md5sum {} + | sort > /tmp/src.md5
cd dest/ && md5sum -c /tmp/src.md5
Monitoring Transfer Speed¶
# Real-time progress with overall stats
rsync -av --progress --stats source/ dest/
# Human-readable summary at the end
rsync -av --stats source/ dest/ 2>&1 | tail -20
# Monitor bandwidth usage during transfer (separate terminal)
watch -n1 'cat /proc/net/dev | grep eth0'
# Use pv for pipeline monitoring
tar cf - source/ | pv | ssh user@host 'tar xf - -C /dest/'
# (Alternative to rsync when you want visual throughput)
Common Scenarios¶
Server Migration¶
Moving an application from old server to new server with minimal downtime:
# Phase 1: Initial bulk sync (while app is still running on old server)
rsync -avzP --delete \
--exclude='/var/run/' \
--exclude='/proc/' \
--exclude='/sys/' \
--exclude='*.pid' \
--exclude='*.sock' \
-e 'ssh -i ~/.ssh/migration_key' \
/ newserver:/
# Phase 2: Stop the application, do a final delta sync
# (This is fast because phase 1 already transferred most data)
systemctl stop myapp
rsync -avz --delete \
--exclude='/var/run/' \
--exclude='/proc/' \
--exclude='/sys/' \
-e 'ssh -i ~/.ssh/migration_key' \
/ newserver:/
# Phase 3: Verify critical paths
rsync -avnc /etc/ newserver:/etc/
rsync -avnc /var/lib/myapp/ newserver:/var/lib/myapp/
# Phase 4: Cut over DNS / load balancer to new server
For application-level migration (not full OS):
# Sync the application directory
rsync -avz --delete \
--exclude='.env' \
--exclude='logs/' \
--exclude='*.pid' \
--exclude='node_modules/' \
/opt/myapp/ newserver:/opt/myapp/
# Sync the data directory separately (might be large)
rsync -avzP --bwlimit=100m \
/data/myapp/ newserver:/data/myapp/
Incremental Backup Rotation (Daily/Weekly/Monthly with --link-dest)¶
A production backup scheme that maintains daily, weekly, and monthly snapshots using hard links for space efficiency:
#!/bin/bash
# incremental-backup.sh -- production backup with rotation
set -euo pipefail
SOURCE="/data"
BACKUP_ROOT="/backups"
DATE=$(date +%F)
DAY_OF_WEEK=$(date +%u) # 1=Monday, 7=Sunday
DAY_OF_MONTH=$(date +%d)
DAILY_DIR="${BACKUP_ROOT}/daily/${DATE}"
LATEST_DAILY=$(ls -1d ${BACKUP_ROOT}/daily/20* 2>/dev/null | tail -1 || true)
# --- Daily backup with --link-dest ---
LINK_OPT=""
if [ -n "$LATEST_DAILY" ] && [ "$LATEST_DAILY" != "$DAILY_DIR" ]; then
LINK_OPT="--link-dest=${LATEST_DAILY}"
fi
rsync -a --delete \
$LINK_OPT \
--exclude-from=/etc/backup-excludes.txt \
"${SOURCE}/" "${DAILY_DIR}/"
echo "$(date -Iseconds) daily backup complete: ${DAILY_DIR}" >> /var/log/backup.log
# --- Weekly snapshot (Sunday) ---
if [ "$DAY_OF_WEEK" -eq 7 ]; then
WEEKLY_DIR="${BACKUP_ROOT}/weekly/$(date +%G-W%V)"
cp -al "${DAILY_DIR}" "${WEEKLY_DIR}"
echo "$(date -Iseconds) weekly snapshot: ${WEEKLY_DIR}" >> /var/log/backup.log
fi
# --- Monthly snapshot (1st of month) ---
if [ "$DAY_OF_MONTH" -eq "01" ]; then
MONTHLY_DIR="${BACKUP_ROOT}/monthly/$(date +%Y-%m)"
cp -al "${DAILY_DIR}" "${MONTHLY_DIR}"
echo "$(date -Iseconds) monthly snapshot: ${MONTHLY_DIR}" >> /var/log/backup.log
fi
# --- Prune old backups ---
find "${BACKUP_ROOT}/daily/" -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
find "${BACKUP_ROOT}/weekly/" -maxdepth 1 -type d -mtime +90 -exec rm -rf {} +
find "${BACKUP_ROOT}/monthly/" -maxdepth 1 -type d -mtime +365 -exec rm -rf {} +
The cp -al command creates hard-linked copies instantly, regardless of directory size. Each weekly/monthly snapshot is a complete directory tree that shares inodes with the daily backup.
Syncing Config Across a Fleet¶
#!/bin/bash
# sync-config.sh -- push config to all servers
set -euo pipefail
CONFIG_DIR="/etc/myapp"
SERVERS_FILE="/etc/fleet-servers.txt" # one hostname per line
FAILED=""
while IFS= read -r server; do
echo "--- Syncing to ${server} ---"
if rsync -avz --delete \
--exclude='local-overrides.conf' \
--timeout=30 \
-e 'ssh -o ConnectTimeout=10 -o BatchMode=yes' \
"${CONFIG_DIR}/" "${server}:${CONFIG_DIR}/"; then
echo "OK: ${server}"
else
echo "FAIL: ${server}"
FAILED="${FAILED} ${server}"
fi
done < "$SERVERS_FILE"
if [ -n "$FAILED" ]; then
echo "FAILED SERVERS:${FAILED}" >&2
exit 1
fi
For larger fleets (50+ servers), parallelize with GNU parallel or xargs:
# Parallel sync to fleet (8 at a time)
cat /etc/fleet-servers.txt | \
xargs -P8 -I{} rsync -avz --delete \
--timeout=30 \
-e 'ssh -o ConnectTimeout=10 -o BatchMode=yes' \
/etc/myapp/ {}:/etc/myapp/
Deploying Static Sites¶
#!/bin/bash
# deploy-static.sh -- deploy a built static site
set -euo pipefail
BUILD_DIR="./build"
DEPLOY_HOST="web@cdn-origin.example.com"
DEPLOY_PATH="/var/www/site"
# Verify build exists
if [ ! -d "$BUILD_DIR" ] || [ ! -f "${BUILD_DIR}/index.html" ]; then
echo "ERROR: Build directory missing or incomplete" >&2
exit 1
fi
# Dry run first
echo "=== Dry run ==="
rsync -avn --delete \
--exclude='.htaccess' \
--exclude='uploads/' \
"${BUILD_DIR}/" "${DEPLOY_HOST}:${DEPLOY_PATH}/"
read -p "Deploy? [y/N] " confirm
if [ "$confirm" != "y" ]; then
echo "Aborted."
exit 0
fi
# Deploy with delete, keeping uploads and .htaccess
rsync -avz --delete \
--exclude='.htaccess' \
--exclude='uploads/' \
"${BUILD_DIR}/" "${DEPLOY_HOST}:${DEPLOY_PATH}/"
echo "Deploy complete. Invalidate CDN cache if needed."
Replicating Large Datasets¶
# Initial transfer of a large dataset (terabytes)
# Use --partial-dir so incomplete files don't pollute destination
# Use --bwlimit to avoid saturating the link
rsync -av \
--partial-dir=.rsync-tmp \
--bwlimit=200m \
--progress \
--stats \
--timeout=600 \
/exports/dataset-2025/ remote:/imports/dataset-2025/
# If interrupted, just re-run the same command -- it resumes
# Check completion after:
rsync -avnc /exports/dataset-2025/ remote:/imports/dataset-2025/
For extremely large datasets, consider splitting the sync:
# Sync in directory chunks to make progress visible
for dir in /exports/dataset-2025/*/; do
dirname=$(basename "$dir")
echo "=== Syncing ${dirname} ==="
rsync -av --partial-dir=.rsync-tmp \
--bwlimit=200m \
"${dir}" "remote:/imports/dataset-2025/${dirname}/"
done
Disaster Recovery Data Movement¶
#!/bin/bash
# dr-sync.sh -- sync critical data to DR site
set -euo pipefail
DR_HOST="dr-backup@dr-site.example.com"
LOCKFILE="/var/run/dr-sync.lock"
LOGFILE="/var/log/dr-sync.log"
# Ensure single instance
exec 200>"$LOCKFILE"
if ! flock -n 200; then
echo "$(date -Iseconds) DR sync already running, skipping" >> "$LOGFILE"
exit 0
fi
log() { echo "$(date -Iseconds) $*" >> "$LOGFILE"; }
log "START dr-sync"
# Critical databases (small, must be consistent)
log "syncing database dumps"
rsync -az --delete \
--timeout=120 \
/backups/db-dumps/ "${DR_HOST}:/dr/db-dumps/"
# Application data (large, can tolerate eventual consistency)
log "syncing application data"
rsync -az --delete \
--partial-dir=.rsync-partial \
--bwlimit=100m \
--timeout=600 \
/data/app/ "${DR_HOST}:/dr/app-data/"
# Config and secrets (small, critical)
log "syncing config"
rsync -az --delete \
/etc/myapp/ "${DR_HOST}:/dr/config/"
log "END dr-sync"
Syncing to/from S3-Compatible Storage (rclone Comparison)¶
rsync does not natively speak S3. For S3-compatible storage, use rclone, which provides rsync-like semantics:
# rsync to local/remote filesystem
rsync -av --delete /data/ remote:/data/
# Equivalent with rclone to S3
rclone sync /data/ s3remote:my-bucket/data/ --progress
# rclone from S3 to local
rclone sync s3remote:my-bucket/data/ /data/ --progress
# rclone with bandwidth limit (same concept as rsync --bwlimit)
rclone sync /data/ s3remote:my-bucket/data/ --bwlimit 50M
# rclone dry run (same concept as rsync -n)
rclone sync /data/ s3remote:my-bucket/data/ --dry-run
Key differences from rsync:
| Feature | rsync | rclone |
|---|---|---|
| Delta transfer | Yes (block-level) | No (whole-file only) |
| S3/GCS/Azure support | No | Yes |
| SSH transport | Built-in | Via SFTP backend |
| Checksum comparison | MD5 | Backend-specific (S3: ETag/MD5) |
| Permissions/ownership | Full POSIX | N/A for object storage |
| Bandwidth limiting | --bwlimit |
--bwlimit |
Use rsync for filesystem-to-filesystem. Use rclone for anything involving object storage.
Operational Patterns¶
rsync in Cron with Locking (flock)¶
Never run rsync from cron without file locking. If a sync takes longer than the cron interval, you get overlapping processes fighting over the same destination:
# /etc/cron.d/rsync-backup
# Run every hour, skip if previous run is still going
0 * * * * root flock -n /var/run/rsync-backup.lock \
rsync -a --delete /data/ /backups/hourly/ \
>> /var/log/rsync-backup.log 2>&1
With a wrapper script for better control:
#!/bin/bash
# /usr/local/bin/rsync-cron.sh
set -euo pipefail
LOCKFILE="/var/run/rsync-${1:-default}.lock"
LOGFILE="/var/log/rsync-${1:-default}.log"
MAX_RUNTIME=3600 # kill after 1 hour
exec 200>"$LOCKFILE"
if ! flock -n 200; then
echo "$(date -Iseconds) SKIP: previous run still active" >> "$LOGFILE"
exit 0
fi
# Timeout protection
timeout "$MAX_RUNTIME" rsync -a --delete \
--exclude-from=/etc/rsync-excludes.txt \
"$2" "$3" \
>> "$LOGFILE" 2>&1
EXIT_CODE=$?
if [ $EXIT_CODE -eq 124 ]; then
echo "$(date -Iseconds) TIMEOUT: sync killed after ${MAX_RUNTIME}s" >> "$LOGFILE"
elif [ $EXIT_CODE -ne 0 ]; then
echo "$(date -Iseconds) ERROR: rsync exited with code ${EXIT_CODE}" >> "$LOGFILE"
else
echo "$(date -Iseconds) OK: sync complete" >> "$LOGFILE"
fi
rsync Daemon Mode¶
For high-frequency syncs or when SSH overhead is undesirable, rsync can run as a daemon listening on port 873:
# /etc/rsyncd.conf
uid = nobody
gid = nogroup
use chroot = yes
max connections = 10
log file = /var/log/rsyncd.log
pid file = /var/run/rsyncd.pid
[data]
path = /data/shared
comment = Shared data
read only = no
auth users = syncuser
secrets file = /etc/rsyncd.secrets
hosts allow = 10.0.0.0/8
[backups]
path = /backups
comment = Backup target
read only = no
auth users = backupuser
secrets file = /etc/rsyncd.secrets
hosts allow = 10.0.1.0/24
# Start daemon
rsync --daemon --config=/etc/rsyncd.conf
# Client connects using double-colon syntax (daemon mode, not SSH)
rsync -av /data/ syncuser@backupserver::data/
# Or with rsync:// URL
rsync -av /data/ rsync://syncuser@backupserver/data/
# The secrets file format: username:password
echo "syncuser:s3cretP4ss" > /etc/rsyncd.secrets
chmod 600 /etc/rsyncd.secrets
Daemon mode is faster than SSH (no encryption overhead) but less secure. Use it only on trusted networks. For untrusted networks, tunnel through SSH:
# rsync daemon over SSH tunnel
ssh -L 873:localhost:873 backupserver &
rsync -av /data/ rsync://localhost/data/
rsync Over SSH with Key Auth¶
Production rsync over SSH requires proper key setup with restrictions:
# On the destination server, restrict the deploy key in authorized_keys:
# ~/.ssh/authorized_keys
command="rsync --server --daemon .",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA... deploy@source
# Or restrict to specific rsync receive commands:
command="rsync --server -vlogDtpre.iLsfxCIvu . /var/www/app/",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA... deploy@ci
For a more flexible approach, use rrsync (restricted rsync), which ships with rsync:
# authorized_keys with rrsync -- limits to a specific directory
command="/usr/bin/rrsync /var/www/app",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA... deploy@ci
# The client syncs normally -- rrsync enforces the path restriction
rsync -avz ./dist/ deploy@prod:/
# rrsync rewrites this to /var/www/app/ on the server side
Wrapper Scripts with Logging and Error Handling¶
A production-grade rsync wrapper:
#!/bin/bash
# /usr/local/bin/managed-rsync.sh
# Production rsync wrapper with logging, alerting, and error handling
set -euo pipefail
# --- Configuration ---
SCRIPT_NAME=$(basename "$0")
LOG_DIR="/var/log/rsync"
ALERT_EMAIL="ops@example.com"
ALERT_ON_FAILURE=true
MAX_RETRIES=3
RETRY_DELAY=60
# --- Argument parsing ---
SOURCE="${1:?Usage: $SCRIPT_NAME SOURCE DEST [LABEL]}"
DEST="${2:?Usage: $SCRIPT_NAME SOURCE DEST [LABEL]}"
LABEL="${3:-$(echo "$SOURCE" | tr '/' '-' | sed 's/^-//')}"
LOGFILE="${LOG_DIR}/${LABEL}.log"
LOCKFILE="/var/run/rsync-${LABEL}.lock"
mkdir -p "$LOG_DIR"
log() {
echo "$(date -Iseconds) [$LABEL] $*" | tee -a "$LOGFILE"
}
alert() {
if [ "$ALERT_ON_FAILURE" = true ]; then
echo "$*" | mail -s "rsync FAILED: ${LABEL}" "$ALERT_EMAIL" 2>/dev/null || true
fi
}
# --- Locking ---
exec 200>"$LOCKFILE"
if ! flock -n 200; then
log "SKIP: previous sync still running"
exit 0
fi
# --- Transfer with retries ---
log "START: ${SOURCE} -> ${DEST}"
START_TIME=$(date +%s)
for attempt in $(seq 1 $MAX_RETRIES); do
log "Attempt ${attempt}/${MAX_RETRIES}"
if rsync -az --delete \
--partial-dir=.rsync-partial \
--timeout=300 \
--stats \
--log-file="${LOGFILE}" \
--exclude-from=/etc/rsync-excludes.txt \
"${SOURCE}" "${DEST}"; then
END_TIME=$(date +%s)
DURATION=$(( END_TIME - START_TIME ))
log "OK: completed in ${DURATION}s"
exit 0
fi
RC=$?
log "WARN: attempt ${attempt} failed with exit code ${RC}"
if [ $attempt -lt $MAX_RETRIES ]; then
log "Retrying in ${RETRY_DELAY}s..."
sleep "$RETRY_DELAY"
fi
done
END_TIME=$(date +%s)
DURATION=$(( END_TIME - START_TIME ))
log "FAIL: all ${MAX_RETRIES} attempts failed after ${DURATION}s"
alert "rsync ${LABEL} failed after ${MAX_RETRIES} attempts (${DURATION}s). Check ${LOGFILE}"
exit 1
rsync Exit Codes Reference¶
| Code | Meaning | Action |
|---|---|---|
| 0 | Success | None |
| 1 | Syntax or usage error | Fix command |
| 2 | Protocol incompatibility | Version mismatch between client/server |
| 3 | Errors selecting I/O files | Check permissions |
| 5 | Error starting client-server protocol | Daemon config issue |
| 10 | Error in socket I/O | Network issue, retry |
| 11 | Error in file I/O | Disk full, permissions |
| 12 | Error in rsync protocol data stream | Network corruption, retry |
| 13 | Errors with program diagnostics | Check rsync version |
| 14 | Error in IPC code | Internal error |
| 20 | Received SIGUSR1 or SIGINT | Killed by user/timeout |
| 21 | waitpid() error | System issue |
| 22 | Error allocating memory | OOM, reduce file count |
| 23 | Partial transfer (some files not transferred) | Check per-file errors in log |
| 24 | Partial transfer (vanished source files) | Source changed during sync |
| 25 | --max-delete limit reached | Increase limit or investigate |
| 30 | Timeout in data send/receive | Network issue, increase --timeout |
| 35 | Timeout waiting for daemon connection | Daemon down or firewall |
Exit code 24 is common in production (files being actively written to during sync) and is usually safe to ignore. Exit code 23 requires investigation -- some files failed to transfer.