rsync is the most widely deployed file synchronization tool on Linux, and for good reason. It handles incremental transfers, preserves permissions and timestamps, works over SSH, and has been battle-tested for decades. Pair it with a well-structured bash script and a systemd timer, and you get a production-grade backup pipeline that most teams never outgrow.
This guide builds exactly that: a backup script with structured logging, exclude patterns, bandwidth limiting, and hardlink-based retention (daily, weekly, monthly snapshots that share unchanged files on disk). The systemd timer replaces cron with better logging integration, missed-run recovery, and resource controls. Everything here runs on rsync over SSH, which means the backup target only needs port 22 open.
Verified working: March 2026 on Ubuntu 24.04.4 LTS (rsync 3.2.7) and Rocky Linux 10.1 (rsync 3.4.1)
Prerequisites
- Two Linux servers running Ubuntu 24.04 or Rocky Linux 10
- Source server (10.0.1.50): the machine being backed up
- Backup target (10.0.1.51): remote server that receives and stores backups via SSH
- Root or sudo access on both servers
- SSH key-based authentication between source and target (configured below)
- rsync installed on both servers (pre-installed on Ubuntu 24.04 and Rocky Linux 10)
Confirm rsync is available on both machines:
rsync --version | head -1
On Ubuntu 24.04, this returns:
rsync version 3.2.7 protocol version 31
Rocky Linux 10 ships with a newer build:
rsync version 3.4.1 protocol version 32
Set Up SSH Key Authentication
The backup script runs unattended, so it needs passwordless SSH from the source server to the backup target. Generate an Ed25519 key pair on the source (10.0.1.50):
sudo ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ''
Copy the public key to the backup target:
sudo ssh-copy-id -i /root/.ssh/id_ed25519.pub [email protected]
Test the connection. This should return the target’s hostname without prompting for a password:
sudo ssh -i /root/.ssh/id_ed25519 [email protected] hostname
This should return the target’s hostname without prompting for a password:
backup-server
If SSH prompts for a password, check that /root/.ssh/authorized_keys on the target has the correct public key and that permissions are 600 on the file and 700 on the .ssh directory.
Create Backup Directories on the Target
On the backup target (10.0.1.51), create the directory structure for daily, weekly, and monthly snapshots. The server01 prefix keeps things organized if you back up multiple machines to the same target.
sudo mkdir -p /backup/server01/{daily,weekly,monthly}
Verify the structure:
tree /backup/server01
The output should show three empty directories:
/backup/server01
├── daily
├── monthly
└── weekly
3 directories, 0 files
Write the Backup Script
This is the core of the setup. The script handles multiple backup sources, exclude patterns, bandwidth limiting, hardlink-based deduplication across snapshots, and automatic retention cleanup. Each run creates a date-stamped daily snapshot. On Sundays it copies the daily snapshot to weekly (using hardlinks so no extra disk space is consumed). On the first of the month, it does the same for monthly. A latest symlink always points to the most recent daily backup, which rsync uses as the --link-dest reference for the next run.
Create the script file:
sudo vi /usr/local/bin/rsync-backup.sh
Add the following script:
#!/bin/bash
#
# rsync-backup.sh - Production backup with hardlink-based retention
# Runs daily via systemd timer. Keeps daily/weekly/monthly snapshots.
#
set -euo pipefail
# ============================================================
# Configuration
# ============================================================
REMOTE_HOST="10.0.1.51"
REMOTE_USER="root"
REMOTE_DIR="/backup/server01"
SSH_KEY="/root/.ssh/id_ed25519"
# Directories to back up (add or remove as needed)
BACKUP_SOURCES=(
"/etc"
"/srv/data"
"/var/spool/cron"
"/root"
)
# Patterns to exclude from backup
EXCLUDE_PATTERNS=(
"lost+found"
".cache"
"*.tmp"
"*.swap"
"*.swp"
"__pycache__"
".terraform"
"node_modules"
)
# Retention settings (number of snapshots to keep)
RETENTION_DAILY=7
RETENTION_WEEKLY=4
RETENTION_MONTHLY=6
# Bandwidth limit in KB/s (0 = unlimited)
BANDWIDTH_LIMIT=5000
# Logging
LOG_DIR="/var/log/rsync-backup"
LOG_FILE="${LOG_DIR}/backup-$(date '+%Y%m%d-%H%M%S').log"
# ============================================================
# Functions
# ============================================================
log() {
local level="$1"
shift
local msg="$*"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[${timestamp}] [${level}] ${msg}" | tee -a "${LOG_FILE}"
}
build_excludes() {
local excludes=""
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
excludes="${excludes} --exclude=${pattern}"
done
echo "${excludes}"
}
run_backup() {
local source_dir="$1"
local dest_dir="$2"
local link_ref="$3"
local excludes
excludes=$(build_excludes)
local link_dest_flag=""
if [ -n "${link_ref}" ] && ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" "[ -d '${link_ref}' ]" 2>/dev/null; then
link_dest_flag="--link-dest=${link_ref}"
fi
local source_name
source_name=$(basename "${source_dir}")
log "INFO" "Backing up ${source_dir} to ${REMOTE_HOST}:${dest_dir}/${source_name}/"
ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p '${dest_dir}/${source_name}'"
# shellcheck disable=SC2086
rsync -avz --delete --stats \
--bwlimit="${BANDWIDTH_LIMIT}" \
${link_dest_flag} \
${excludes} \
-e "ssh -i ${SSH_KEY}" \
"${source_dir}/" \
"${REMOTE_USER}@${REMOTE_HOST}:${dest_dir}/${source_name}/" 2>&1 | tee -a "${LOG_FILE}"
local rc=${PIPESTATUS[0]}
if [ "${rc}" -eq 0 ]; then
log "INFO" "Completed: ${source_dir}"
elif [ "${rc}" -eq 24 ]; then
log "WARN" "Completed with vanished files: ${source_dir} (exit 24, safe to ignore)"
else
log "ERROR" "Failed: ${source_dir} (exit code ${rc})"
return "${rc}"
fi
}
rotate_backups() {
local today
today=$(date '+%Y%m%d')
local daily_dir="${REMOTE_DIR}/daily/${today}"
local latest_link="${REMOTE_DIR}/daily/latest"
local day_of_week
day_of_week=$(date '+%u')
local day_of_month
day_of_month=$(date '+%d')
log "INFO" "Starting backup rotation for ${today}"
# Determine link-dest reference (previous latest snapshot)
local link_ref=""
link_ref=$(ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" \
"readlink -f '${latest_link}' 2>/dev/null || echo ''")
# Create today's daily directory
ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p '${daily_dir}'"
# Back up each source directory
local failed=0
for src in "${BACKUP_SOURCES[@]}"; do
if [ -d "${src}" ]; then
run_backup "${src}" "${daily_dir}" "${link_ref}" || ((failed++))
else
log "WARN" "Source directory does not exist, skipping: ${src}"
fi
done
# Update the latest symlink
ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" \
"ln -snf '${daily_dir}' '${latest_link}'"
log "INFO" "Updated latest symlink to ${daily_dir}"
# Weekly snapshot on Sundays (day 7)
if [ "${day_of_week}" -eq 7 ]; then
local week_label
week_label=$(date '+%Y-W%V')
local weekly_dir="${REMOTE_DIR}/weekly/${week_label}"
ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" \
"cp -al '${daily_dir}' '${weekly_dir}'"
log "INFO" "Weekly snapshot created: ${weekly_dir}"
fi
# Monthly snapshot on the 1st
if [ "${day_of_month}" -eq "01" ]; then
local month_label
month_label=$(date '+%Y-%m')
local monthly_dir="${REMOTE_DIR}/monthly/${month_label}"
ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" \
"cp -al '${daily_dir}' '${monthly_dir}'"
log "INFO" "Monthly snapshot created: ${monthly_dir}"
fi
# Retention cleanup
log "INFO" "Running retention cleanup"
# Remove daily snapshots older than retention period
ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" \
"cd '${REMOTE_DIR}/daily' && ls -d [0-9]* 2>/dev/null | sort -r | tail -n +$((RETENTION_DAILY + 1)) | xargs -r rm -rf"
# Remove weekly snapshots older than retention period
ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" \
"cd '${REMOTE_DIR}/weekly' && ls -d [0-9]* 2>/dev/null | sort -r | tail -n +$((RETENTION_WEEKLY + 1)) | xargs -r rm -rf"
# Remove monthly snapshots older than retention period
ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" \
"cd '${REMOTE_DIR}/monthly' && ls -d [0-9]* 2>/dev/null | sort -r | tail -n +$((RETENTION_MONTHLY + 1)) | xargs -r rm -rf"
log "INFO" "Retention cleanup complete"
if [ "${failed}" -gt 0 ]; then
log "ERROR" "Backup finished with ${failed} failed source(s)"
return 1
fi
log "INFO" "All backups completed successfully"
}
# ============================================================
# Main
# ============================================================
mkdir -p "${LOG_DIR}"
log "INFO" "=========================================="
log "INFO" "rsync backup started"
log "INFO" "=========================================="
START_TIME=$(date +%s)
rotate_backups
RC=$?
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
log "INFO" "Total runtime: ${DURATION} seconds"
# Clean up old log files (keep 30 days)
find "${LOG_DIR}" -name "backup-*.log" -mtime +30 -delete 2>/dev/null || true
exit ${RC}
Make the script executable:
sudo chmod +x /usr/local/bin/rsync-backup.sh
The BACKUP_SOURCES array controls what gets backed up. Adjust it to match your environment. Common additions include /home, /var/lib/postgresql, or application data directories. The EXCLUDE_PATTERNS array keeps cache files, temporary data, and build artifacts out of backups.
Hardlinks are the key to making this space-efficient. When rsync runs with --link-dest pointing to the previous snapshot, unchanged files are hardlinked rather than copied. A week of daily backups might consume only slightly more disk space than a single full backup. The cp -al command for weekly and monthly snapshots uses the same technique.
Test the Backup Script
Run the script manually to verify everything works before setting up the timer. Execute it as root since it needs access to system directories:
sudo /usr/local/bin/rsync-backup.sh
The script logs to both the terminal and the log file. A successful first run looks like this:
[2026-03-30 11:42:08] [INFO] ==========================================
[2026-03-30 11:42:08] [INFO] rsync backup started
[2026-03-30 11:42:08] [INFO] ==========================================
[2026-03-30 11:42:08] [INFO] Starting backup rotation for 20260330
[2026-03-30 11:42:09] [INFO] Backing up /etc to 10.0.1.51:/backup/server01/daily/20260330/etc/
sending incremental file list
./
NetworkManager/
NetworkManager/conf.d/
...
Number of files: 1,705 (reg: 839, dir: 232, link: 634)
Number of created files: 1,705 (reg: 839, dir: 232, link: 634)
Number of deleted files: 0
Number of regular files transferred: 839
Total file size: 12,927,418 bytes
Total transferred file size: 12,927,418 bytes
[2026-03-30 11:42:11] [INFO] Completed: /etc
[2026-03-30 11:42:11] [INFO] Backing up /srv/data to 10.0.1.51:/backup/server01/daily/20260330/data/
sending incremental file list
./
Number of files: 34 (reg: 28, dir: 6)
Number of created files: 34 (reg: 28, dir: 6)
Number of regular files transferred: 28
Total file size: 385,201 bytes
Total transferred file size: 385,201 bytes
[2026-03-30 11:42:12] [INFO] Completed: /srv/data
[2026-03-30 11:42:12] [INFO] Updated latest symlink to /backup/server01/daily/20260330
[2026-03-30 11:42:12] [INFO] Running retention cleanup
[2026-03-30 11:42:12] [INFO] Retention cleanup complete
[2026-03-30 11:42:12] [INFO] All backups completed successfully
[2026-03-30 11:42:12] [INFO] Total runtime: 4 seconds
The first run transfers everything because no previous snapshot exists for hardlinking. Subsequent runs only transfer changed files, which is dramatically faster. In testing, a second run with no changes transferred 0 bytes and completed in under 2 seconds.
Check the backup structure on the target server:
ssh [email protected] "find /backup/server01 -maxdepth 3 -type d | sort"
The directory tree confirms the date-stamped snapshot with each backed-up source as a subdirectory:
/backup/server01
/backup/server01/daily
/backup/server01/daily/20260330
/backup/server01/daily/20260330/cron
/backup/server01/daily/20260330/data
/backup/server01/daily/20260330/etc
/backup/server01/daily/20260330/root
/backup/server01/monthly
/backup/server01/weekly
Verify the latest symlink points to today’s snapshot:
ssh [email protected] "ls -la /backup/server01/daily/latest"
The symlink should resolve to the current date:
lrwxrwxrwx. 1 root root 38 Mar 30 11:42 /backup/server01/daily/latest -> /backup/server01/daily/20260330
Check disk usage of the snapshot:
ssh [email protected] "du -sh /backup/server01/daily/20260330/"
The first full backup consumed about 13 MB in this test environment:
13M /backup/server01/daily/20260330/
Create the Systemd Timer
Systemd timers are a better fit than cron for production backups. They integrate with journald for centralized logging, support Persistent=true to catch up on missed runs after downtime, and allow resource controls like I/O scheduling priority. If you want a deeper comparison between cron and systemd timers, we covered that separately.
Two unit files are needed: a service unit that defines what to run, and a timer unit that defines when to run it.
Create the service unit:
sudo vi /etc/systemd/system/rsync-backup.service
Paste this service definition:
[Unit]
Description=rsync backup to remote server
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/rsync-backup.sh
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=3600
[Install]
WantedBy=multi-user.target
The Nice=10 and IOSchedulingClass=idle settings ensure backups run at low CPU and I/O priority, so they don’t interfere with production workloads. TimeoutStartSec=3600 gives large backups up to an hour to complete before systemd kills the process.
Now create the timer unit:
sudo vi /etc/systemd/system/rsync-backup.timer
The timer fires daily at 2 AM with a 15-minute random delay to avoid thundering herd on multi-server setups:
[Unit]
Description=Daily rsync backup timer
[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=900
Persistent=true
[Install]
WantedBy=timers.target
Three settings matter here. OnCalendar=*-*-* 02:00:00 fires at 2:00 AM daily. RandomizedDelaySec=900 adds a random delay of up to 15 minutes, which prevents multiple servers from hammering the backup target simultaneously. Persistent=true is critical for production: if the server was off or asleep at 2:00 AM, the backup runs immediately after the next boot.
Reload systemd and enable the timer:
sudo systemctl daemon-reload
sudo systemctl enable --now rsync-backup.timer
Confirm the timer is active and shows the next scheduled run:
systemctl list-timers rsync-backup.timer
The output shows when the next backup will trigger:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-03-31 02:03:33 UTC 14h left - - rsync-backup.timer rsync-backup.service
1 timers listed.
The random delay explains why NEXT shows 02:03 instead of exactly 02:00.
Verify a Manual Run Through Systemd
Triggering the backup through systemd (rather than running the script directly) confirms that the service unit, environment, and permissions all work correctly:
sudo systemctl start rsync-backup.service
Watch the progress in the journal:
sudo journalctl -u rsync-backup.service --no-pager -n 20
The journal output mirrors the script’s log messages:
Mar 30 11:58:02 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:02] [INFO] ==========================================
Mar 30 11:58:02 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:02] [INFO] rsync backup started
Mar 30 11:58:02 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:02] [INFO] ==========================================
Mar 30 11:58:02 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:02] [INFO] Starting backup rotation for 20260330
Mar 30 11:58:03 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:03] [INFO] Backing up /etc to 10.0.1.51:...
Mar 30 11:58:05 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:05] [INFO] Completed: /etc
Mar 30 11:58:06 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:06] [INFO] Completed: /srv/data
Mar 30 11:58:06 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:06] [INFO] All backups completed successfully
Mar 30 11:58:06 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:06] [INFO] Total runtime: 4 seconds
Mar 30 11:58:06 source-server systemd[1]: rsync-backup.service: Deactivated successfully.
Mar 30 11:58:06 source-server systemd[1]: Finished rsync backup to remote server.
The “Deactivated successfully” line confirms systemd is happy. If the service fails, systemctl status rsync-backup.service shows the exit code and the last log lines.
For monitoring, you can also set up email or Slack alerts on failure. A simple approach is adding [email protected] to the [Unit] section of the service file, with a corresponding notification service unit.
Firewall Configuration
Since rsync runs over SSH, the backup target (10.0.1.51) only needs port 22 open. The source server initiates outbound connections, so no firewall changes are needed there.
On Rocky Linux 10 (backup target), ensure SSH is allowed through firewalld:
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --reload
Verify the rule is active:
sudo firewall-cmd --list-services
SSH should appear in the output:
cockpit dhcpv6-client ssh
On Ubuntu 24.04 (backup target):
sudo ufw allow ssh
sudo ufw enable
Confirm the rule:
sudo ufw status
The output confirms SSH is permitted:
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
22/tcp (v6) ALLOW Anywhere (v6)
SELinux on Rocky Linux 10 does not require any special configuration for rsync over SSH. The SSH connection is the transport layer, and SELinux policies already allow SSH traffic. No setsebool or semanage commands are needed.
OS Differences
The backup script and systemd units work identically on both distributions. Here are the minor differences worth knowing:
| Item | Ubuntu 24.04 | Rocky Linux 10 |
|---|---|---|
| rsync version | 3.2.7 | 3.4.1 |
| rsync package | Pre-installed | Pre-installed |
| Firewall | ufw | firewalld |
| SELinux | Not applicable (AppArmor) | Enforcing, no changes needed for rsync over SSH |
| Systemd unit path | /etc/systemd/system/ | /etc/systemd/system/ |
| Default SSH key location | /root/.ssh/ | /root/.ssh/ |
| Log rotation | logrotate available | logrotate available |
Rocky Linux 10 ships with rsync 3.4.1, which includes protocol version 32 and improved transfer performance for large file sets. Ubuntu 24.04 uses the older 3.2.7 with protocol 31. Both are fully compatible when syncing between different versions because rsync negotiates the protocol automatically.
Customizing the Script
A few adjustments worth considering for production deployments.
Adding more backup sources: Edit the BACKUP_SOURCES array in the script. Database data directories (/var/lib/postgresql, /var/lib/mysql) should only be backed up after dumping with pg_dump or mysqldump first, because rsync cannot guarantee a consistent snapshot of a running database. If you need a complete bash-based backup solution with database dumps included, that’s a separate consideration.
Bandwidth limiting: The default BANDWIDTH_LIMIT=5000 caps transfers at roughly 5 MB/s. Set it to 0 for unlimited, or lower it on shared or metered connections. This is especially important when backing up over WAN links.
Retention tuning: The defaults keep 7 daily, 4 weekly, and 6 monthly snapshots. Thanks to hardlinks, the disk overhead is mostly proportional to the rate of file changes, not the number of snapshots. Monitor disk usage on the backup target with df -h and adjust retention values as needed.
Real-time synchronization: If you need continuous file sync rather than scheduled snapshots, rsync combined with lsyncd watches for filesystem changes and triggers immediate transfers. That approach complements scheduled backups rather than replacing them.
Encryption at rest: For encrypted, deduplicated backups with repository-level integrity checks, BorgBackup with borgmatic is worth evaluating. It’s heavier than plain rsync but adds features that matter when compliance requirements exist.
Wrapping Up
The script handles daily, weekly, and monthly rotation using hardlinks for space efficiency, with bandwidth limiting and structured logging built in. The systemd timer replaces cron with better journal integration, resource controls via Nice and IOSchedulingClass, and Persistent=true to catch up on missed runs after reboots or downtime. Both Ubuntu 24.04 and Rocky Linux 10 run the same configuration with no modifications needed.