TL;DR — Quick Summary
rsync guide: delta transfer algorithm, essential flags, SSH remote backups, --link-dest incremental snapshots, cron automation, and daemon mode on Linux.
rsync is the standard Linux tool for efficient file copying and remote backup. Its delta transfer algorithm computes checksums on both sides and sends only the bytes that differ — making it dramatically faster than scp for repeated transfers of large directory trees. This guide covers everything from basic flags through production incremental backup scripts with --link-dest rotation.
Prerequisites
- Linux system (Ubuntu 22.04+, Debian 12+, RHEL 9+, or any modern distro).
- rsync installed on source and destination machines (
sudo apt install rsync/sudo dnf install rsync). - SSH key-based authentication configured for remote transfers.
- Root or sudo access for system-level backups.
How rsync Works
rsync uses the delta transfer algorithm to minimise data sent over the wire:
- The sender lists files and computes rolling checksums of small blocks.
- The receiver checks which blocks it already has.
- Only missing or changed blocks are transmitted.
- Metadata (permissions, timestamps, ownership) is synchronised separately.
For a large directory that changes only 1% per day, rsync typically transfers less than 1% of the total data on subsequent runs. The first transfer is always a full copy; every run after that is incremental by default.
Key properties:
- Checksum-based — detects changes even if the modification time was not updated.
- Atomic-like — files are written to a temporary name and renamed on completion.
- Cross-platform — runs on Linux, macOS, and Windows (via WSL or Cygwin).
- Protocol version negotiated — client and server agree on the highest supported version at connect time.
Basic Syntax
rsync [OPTIONS] SOURCE DESTINATION
| Pattern | Meaning |
|---|---|
rsync -a /src/ /dst/ | Copy contents of /src/ into /dst/ |
rsync -a /src /dst/ | Copy the /src directory itself into /dst/ |
rsync -a user@host:/remote/ /local/ | Pull from remote to local |
rsync -a /local/ user@host:/remote/ | Push from local to remote |
The trailing slash on the source matters. /src/ means “copy the contents of src”; /src means “copy the directory src itself into the destination.”
Essential Flags
rsync -avzP --delete \
--exclude='*.tmp' \
--exclude='.cache/' \
/home/user/ user@backupserver:/backups/home/
| Flag | Effect |
|---|---|
-a | Archive: recursive + preserve permissions, ownership, timestamps, symlinks, devices |
-v | Verbose: print each file transferred |
-z | Compress data during transfer (useful on slow links; skip on fast LAN) |
-P | Show per-file progress + keep partial files (--progress --partial combined) |
--delete | Delete destination files absent from source — creates an exact mirror |
-n / --dry-run | Simulate the transfer without making any changes |
--exclude=PATTERN | Skip files matching the pattern |
--include=PATTERN | Force-include files even if a broader exclude would skip them |
-e ssh | Use SSH as the transport (default in modern rsync; add options with -e 'ssh -p 2222') |
--checksum | Compare file contents (not just size+mtime); slower but catches silent corruption |
--bwlimit=KBPS | Cap bandwidth usage (e.g. --bwlimit=10000 for 10 MB/s) |
--log-file=PATH | Write a transfer log to the specified file |
--stats | Print summary statistics after transfer completes |
Always dry-run first
rsync -avzn --delete /important/ user@host:/backup/important/
Review the output, then re-run without -n to apply the changes.
Remote Sync over SSH
Push (local → remote)
rsync -avz -e "ssh -i ~/.ssh/backup_ed25519" \
/var/www/html/ \
backupuser@192.168.1.50:/backups/webroot/
Pull (remote → local)
rsync -avz -e "ssh -i ~/.ssh/backup_ed25519" \
backupuser@192.168.1.50:/backups/webroot/ \
/var/www/html/
Custom SSH port
rsync -avz -e "ssh -p 2222" /src/ user@host:/dst/
Restrict the backup SSH key
On the backup server, in ~/.ssh/authorized_keys, prefix the public key with a forced command so it can only run rsync — not an interactive shell:
command="rsync --server --daemon .",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...key...
This limits the damage if the client machine is compromised.
Bandwidth Limiting
On production systems, unrestricted rsync can saturate a link. --bwlimit accepts kilobytes per second:
# Limit to 5 MB/s
rsync -avz --bwlimit=5000 /source/ user@host:/dest/
# Limit to 1 MB/s for background backup jobs
rsync -avz --bwlimit=1000 --delete /home/ user@host:/backups/home/
For more dynamic throttling, combine rsync with ionice and nice to reduce its impact on the CPU and disk scheduler:
nice -n 19 ionice -c 3 rsync -avz --bwlimit=2000 /source/ user@host:/dest/
Preserving Permissions, Ownership, and Extended Attributes
The -a (archive) flag is a shorthand for -rlptgoD:
| Sub-flag | Preserves |
|---|---|
-r | Recursion |
-l | Symlinks as symlinks |
-p | Permissions (chmod bits) |
-t | Modification timestamps |
-g | Group ownership |
-o | User ownership (requires root) |
-D | Device files and special files |
For ACLs and extended attributes (needed for SELinux contexts or macOS xattrs):
rsync -aAX /source/ /destination/
| Extra flag | Preserves |
|---|---|
-A | POSIX ACLs |
-X | Extended attributes (xattrs) |
On SELinux systems, add --rsync-path="sudo rsync" on the remote side when rsync needs elevated access to read or write xattrs.
Incremental Backups with —link-dest
--link-dest enables hardlink-based snapshot backups — each daily directory looks like a complete copy of your data but only new or changed files occupy extra space. Unchanged files are hard-linked from the previous snapshot.
Concept
/backups/
2026-03-20/ ← full data, 50 GB
2026-03-21/ ← hard-linked unchanged files + only changed files; uses ~200 MB extra
2026-03-22/ ← hard-linked from 2026-03-21 + today's changes; uses ~150 MB extra
Script
#!/bin/bash
set -euo pipefail
SOURCE="/home/"
DEST="backupuser@192.168.1.50:/backups"
TODAY=$(date +%Y-%m-%d)
YESTERDAY=$(date -d "yesterday" +%Y-%m-%d)
SSH_KEY="/root/.ssh/backup_ed25519"
rsync -avz \
-e "ssh -i $SSH_KEY" \
--link-dest="$DEST/daily/$YESTERDAY" \
--delete \
"$SOURCE" \
"$DEST/daily/$TODAY/"
echo "Backup complete: $TODAY"
Rotation: daily / weekly / monthly
#!/bin/bash
# Run daily. Keeps 7 daily, 4 weekly (Sunday), 12 monthly (1st of month).
SOURCE="/home/"
DEST_BASE="/backups"
TODAY=$(date +%Y-%m-%d)
DOW=$(date +%u) # 1=Mon ... 7=Sun
DOM=$(date +%d) # day of month
DAILY_DEST="$DEST_BASE/daily/$TODAY"
LATEST_LINK="$DEST_BASE/daily/latest"
rsync -a --delete \
--link-dest="$LATEST_LINK" \
"$SOURCE" "$DAILY_DEST/"
# Update the 'latest' symlink
ln -sfn "$DAILY_DEST" "$LATEST_LINK"
# Weekly snapshot on Sunday
if [ "$DOW" -eq 7 ]; then
cp -al "$DAILY_DEST" "$DEST_BASE/weekly/$(date +%Y-W%V)"
fi
# Monthly snapshot on the 1st
if [ "$DOM" -eq 01 ]; then
cp -al "$DAILY_DEST" "$DEST_BASE/monthly/$(date +%Y-%m)"
fi
# Prune: keep last 7 daily, 4 weekly, 12 monthly
find "$DEST_BASE/daily" -maxdepth 1 -type d | sort | head -n -7 | xargs rm -rf
find "$DEST_BASE/weekly" -maxdepth 1 -type d | sort | head -n -4 | xargs rm -rf
find "$DEST_BASE/monthly" -maxdepth 1 -type d | sort | head -n -12 | xargs rm -rf
Automation: cron and systemd Timers
cron
crontab -e
# Add:
0 2 * * * /usr/local/bin/rsync-backup.sh >> /var/log/rsync-backup.log 2>&1
systemd service + timer
/etc/systemd/system/rsync-backup.service:
[Unit]
Description=Daily rsync backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
ExecStart=/usr/local/bin/rsync-backup.sh
StandardOutput=journal
StandardError=journal
/etc/systemd/system/rsync-backup.timer:
[Unit]
Description=Daily rsync backup timer
[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now rsync-backup.timer
systemctl list-timers rsync-backup.timer
Systemd timers are preferred over cron because they log to journald, support Persistent=true (catches missed runs after downtime), and integrate with dependency ordering.
rsync Daemon Mode
For high-volume or scheduled pulls without interactive SSH authentication, rsync can run as a standalone daemon.
/etc/rsyncd.conf
# Global settings
uid = nobody
gid = nogroup
use chroot = yes
max connections = 4
log file = /var/log/rsyncd.log
pid file = /var/run/rsyncd.pid
# Module definition
[backups]
path = /backups/shared
comment = Shared backup area
read only = no
auth users = backupclient
secrets file = /etc/rsyncd.secrets
hosts allow = 192.168.1.0/24
hosts deny = *
/etc/rsyncd.secrets
backupclient:strongpassword123
chmod 600 /etc/rsyncd.secrets
Start the daemon
# systemd
systemctl enable --now rsync
# Or standalone
rsync --daemon
Client usage
rsync -avz backupclient@backupserver::backups /local/restore/
The double-colon (::) signals daemon mode (as opposed to SSH mode).
Partial Transfers and Resume
Large file transfers interrupted mid-way can be resumed:
# Keep partial files and resume on next run
rsync -avzP --partial-dir=/tmp/rsync-partial /large-source/ user@host:/dest/
--partial-dir stores incomplete files in a separate directory rather than in the destination, preventing half-written files from being used accidentally. On the next run, rsync detects the partial file and resumes from where it stopped.
Monitoring and Logging
Per-run stats
rsync -avz --stats /source/ /dest/ 2>&1 | tee /var/log/rsync-$(date +%Y%m%d).log
Key stats output:
Number of files: 48,231
Number of created files: 12
Number of deleted files: 3
Number of regular files transferred: 47
Total file size: 18.52G
Total transferred file size: 124.73M
Transfer speed: 8.43M/s
Persistent log file
rsync -avz --log-file=/var/log/rsync-backup.log /source/ /dest/
Check for failures in scripts
rsync -avz /source/ /dest/
EXIT=$?
if [ $EXIT -ne 0 ] && [ $EXIT -ne 24 ]; then
echo "rsync failed with exit code $EXIT" | mail -s "Backup failed on $(hostname)" admin@example.com
fi
Exit code 24 means “some source files vanished during transfer” — normal for live systems; treat as success.
rsync vs scp vs sftp vs Borg vs rclone
| Feature | rsync | scp | sftp | BorgBackup | rclone |
|---|---|---|---|---|---|
| Delta transfer | Yes | No | No | Yes (dedup) | Partial |
| Resume partial | Yes (-P) | No | Yes | Yes | Yes |
| Encryption | Via SSH | SSH | SSH | AES-256 native | Via SSH/API |
| Deduplication | No | No | No | Yes | No |
| Incremental snapshots | Yes (--link-dest) | No | No | Yes | No |
| Cloud backends | No | No | No | SSH only | S3, GCS, many |
| Compression | Yes (-z) | No | No | lz4/zstd | Yes |
| Best for | Mirroring, backups | One-off file copy | Interactive files | Space-efficient backup | Cloud sync |
Production Backup Script
This complete script handles daily incremental backups with --link-dest, weekly and monthly rotation, and failure alerting.
#!/bin/bash
# /usr/local/bin/rsync-backup.sh
# Daily incremental backup with weekly + monthly rotation
set -euo pipefail
# ── Configuration ────────────────────────────────────────────────────────────
SOURCE_DIRS=("/home" "/etc" "/var/www")
BACKUP_HOST="backupuser@192.168.1.50"
BACKUP_ROOT="/backups/$(hostname)"
SSH_KEY="/root/.ssh/backup_ed25519"
ALERT_EMAIL="admin@example.com"
BWLIMIT=5000 # KB/s — 0 = unlimited
LOG="/var/log/rsync-backup.log"
# ── Helpers ───────────────────────────────────────────────────────────────────
TODAY=$(date +%Y-%m-%d)
DOW=$(date +%u)
DOM=$(date +%d)
RSYNC_OPTS="-aAXz --delete --stats --log-file=$LOG"
[ "$BWLIMIT" -gt 0 ] && RSYNC_OPTS="$RSYNC_OPTS --bwlimit=$BWLIMIT"
SSH_CMD="ssh -i $SSH_KEY -o StrictHostKeyChecking=yes -o BatchMode=yes"
alert() { echo "$1" | mail -s "rsync backup FAILED on $(hostname)" "$ALERT_EMAIL"; }
# ── Main backup ───────────────────────────────────────────────────────────────
LATEST="$BACKUP_HOST:$BACKUP_ROOT/daily/latest"
DEST="$BACKUP_HOST:$BACKUP_ROOT/daily/$TODAY"
for SRC in "${SOURCE_DIRS[@]}"; do
rsync $RSYNC_OPTS \
-e "$SSH_CMD" \
--link-dest="$LATEST/$(basename $SRC)" \
"$SRC/" \
"$DEST/$(basename $SRC)/" || { alert "Failed on $SRC"; exit 1; }
done
# Update latest symlink on remote
ssh -i "$SSH_KEY" "${BACKUP_HOST%%:*}" \
"ln -sfn $BACKUP_ROOT/daily/$TODAY $BACKUP_ROOT/daily/latest"
# ── Weekly / Monthly promotion ────────────────────────────────────────────────
ssh -i "$SSH_KEY" "${BACKUP_HOST%%:*}" bash <<EOF
[ "$DOW" -eq 7 ] && cp -al $BACKUP_ROOT/daily/$TODAY $BACKUP_ROOT/weekly/$(date +%Y-W%V)
[ "$DOM" -eq 01 ] && cp -al $BACKUP_ROOT/daily/$TODAY $BACKUP_ROOT/monthly/$(date +%Y-%m)
find $BACKUP_ROOT/daily -maxdepth 1 -mindepth 1 -type d | sort | head -n -7 | xargs -r rm -rf
find $BACKUP_ROOT/weekly -maxdepth 1 -mindepth 1 -type d | sort | head -n -4 | xargs -r rm -rf
find $BACKUP_ROOT/monthly -maxdepth 1 -mindepth 1 -type d | sort | head -n -12 | xargs -r rm -rf
EOF
echo "$(date): backup complete → $TODAY" >> "$LOG"
Summary
- rsync uses delta transfer — only changed file blocks are sent, making repeated backups fast.
- Use
-aAXzto preserve all metadata including ACLs and xattrs; add-Pfor resumable transfers. - Always dry-run with
-nbefore adding--deleteto a new destination. --link-destcreates space-efficient hardlink snapshots — the foundation of professional rotation schemes.- Restrict backup SSH keys in
authorized_keyswith a forced command to limit blast radius. - Prefer systemd timers over cron for automated backups — better logging and missed-run handling.
- Exit code 24 (files vanished) is normal for live systems; treat it as success in backup scripts.
- Run
rsync --statsand log output to detect silent backup failures before you need to restore.