How To

Production rsync Backup with Systemd Timers on Linux

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.

Original content from computingforgeeks.com - post 165097

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:

ItemUbuntu 24.04Rocky Linux 10
rsync version3.2.73.4.1
rsync packagePre-installedPre-installed
Firewallufwfirewalld
SELinuxNot 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 rotationlogrotate availablelogrotate 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.

Related Articles

KVM How to Configure a Directory Storage Pool in KVM Containers Deploy k0s Kubernetes Cluster on Ubuntu 24.04|22.04|20.04|18.04 Security How To Setup WireGuard VPN on Amazon Linux 2 Virtualization How To Install oVirt guest agent on Linux

Leave a Comment

Press ESC to close