{"id":165097,"date":"2026-03-30T17:09:55","date_gmt":"2026-03-30T14:09:55","guid":{"rendered":"https:\/\/computingforgeeks.com\/?p=165097"},"modified":"2026-03-30T17:09:56","modified_gmt":"2026-03-30T14:09:56","slug":"rsync-backup-systemd-timer","status":"publish","type":"post","link":"https:\/\/computingforgeeks.com\/rsync-backup-systemd-timer\/","title":{"rendered":"Production rsync Backup with Systemd Timers on Linux"},"content":{"rendered":"\n<p>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.<\/p>\n\n\n\n<p>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 <a href=\"https:\/\/rsync.samba.org\/\" target=\"_blank\" rel=\"noreferrer noopener\">rsync<\/a> over SSH, which means the backup target only needs port 22 open.<\/p>\n\n\n\n<p><em>Verified working: <strong>March 2026<\/strong> on Ubuntu 24.04.4 LTS (rsync 3.2.7) and Rocky Linux 10.1 (rsync 3.4.1)<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Prerequisites<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Two Linux servers running Ubuntu 24.04 or Rocky Linux 10<\/li>\n<li><strong>Source server<\/strong> (10.0.1.50): the machine being backed up<\/li>\n<li><strong>Backup target<\/strong> (10.0.1.51): remote server that receives and stores backups via SSH<\/li>\n<li>Root or sudo access on both servers<\/li>\n<li>SSH key-based authentication between source and target (configured below)<\/li>\n<li>rsync installed on both servers (pre-installed on Ubuntu 24.04 and Rocky Linux 10)<\/li>\n<\/ul>\n\n\n\n<p>Confirm rsync is available on both machines:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>rsync --version | head -1<\/code><\/pre>\n\n\n\n<p>On Ubuntu 24.04, this returns:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>rsync  version 3.2.7  protocol version 31<\/code><\/pre>\n\n\n\n<p>Rocky Linux 10 ships with a newer build:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>rsync  version 3.4.1  protocol version 32<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Set Up SSH Key Authentication<\/h2>\n\n\n\n<p>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):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ssh-keygen -t ed25519 -f \/root\/.ssh\/id_ed25519 -N ''<\/code><\/pre>\n\n\n\n<p>Copy the public key to the backup target:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ssh-copy-id -i \/root\/.ssh\/id_ed25519.pub root@10.0.1.51<\/code><\/pre>\n\n\n\n<p>Test the connection. This should return the target&#8217;s hostname without prompting for a password:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ssh -i \/root\/.ssh\/id_ed25519 root@10.0.1.51 hostname<\/code><\/pre>\n\n\n\n<p>This should return the target&#8217;s hostname without prompting for a password:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>backup-server<\/code><\/pre>\n\n\n\n<p>If SSH prompts for a password, check that <code>\/root\/.ssh\/authorized_keys<\/code> on the target has the correct public key and that permissions are <code>600<\/code> on the file and <code>700<\/code> on the <code>.ssh<\/code> directory.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Create Backup Directories on the Target<\/h2>\n\n\n\n<p>On the backup target (10.0.1.51), create the directory structure for daily, weekly, and monthly snapshots. The <code>server01<\/code> prefix keeps things organized if you back up multiple machines to the same target.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo mkdir -p \/backup\/server01\/{daily,weekly,monthly}<\/code><\/pre>\n\n\n\n<p>Verify the structure:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>tree \/backup\/server01<\/code><\/pre>\n\n\n\n<p>The output should show three empty directories:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/backup\/server01\n\u251c\u2500\u2500 daily\n\u251c\u2500\u2500 monthly\n\u2514\u2500\u2500 weekly\n\n3 directories, 0 files<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Write the Backup Script<\/h2>\n\n\n\n<p>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 <code>latest<\/code> symlink always points to the most recent daily backup, which rsync uses as the <code>--link-dest<\/code> reference for the next run.<\/p>\n\n\n\n<p>Create the script file:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo vi \/usr\/local\/bin\/rsync-backup.sh<\/code><\/pre>\n\n\n\n<p>Add the following script:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/bin\/bash\n#\n# rsync-backup.sh - Production backup with hardlink-based retention\n# Runs daily via systemd timer. Keeps daily\/weekly\/monthly snapshots.\n#\n\nset -euo pipefail\n\n# ============================================================\n# Configuration\n# ============================================================\nREMOTE_HOST=\"10.0.1.51\"\nREMOTE_USER=\"root\"\nREMOTE_DIR=\"\/backup\/server01\"\nSSH_KEY=\"\/root\/.ssh\/id_ed25519\"\n\n# Directories to back up (add or remove as needed)\nBACKUP_SOURCES=(\n    \"\/etc\"\n    \"\/srv\/data\"\n    \"\/var\/spool\/cron\"\n    \"\/root\"\n)\n\n# Patterns to exclude from backup\nEXCLUDE_PATTERNS=(\n    \"lost+found\"\n    \".cache\"\n    \"*.tmp\"\n    \"*.swap\"\n    \"*.swp\"\n    \"__pycache__\"\n    \".terraform\"\n    \"node_modules\"\n)\n\n# Retention settings (number of snapshots to keep)\nRETENTION_DAILY=7\nRETENTION_WEEKLY=4\nRETENTION_MONTHLY=6\n\n# Bandwidth limit in KB\/s (0 = unlimited)\nBANDWIDTH_LIMIT=5000\n\n# Logging\nLOG_DIR=\"\/var\/log\/rsync-backup\"\nLOG_FILE=\"${LOG_DIR}\/backup-$(date '+%Y%m%d-%H%M%S').log\"\n\n# ============================================================\n# Functions\n# ============================================================\n\nlog() {\n    local level=\"$1\"\n    shift\n    local msg=\"$*\"\n    local timestamp\n    timestamp=$(date '+%Y-%m-%d %H:%M:%S')\n    echo \"[${timestamp}] [${level}] ${msg}\" | tee -a \"${LOG_FILE}\"\n}\n\nbuild_excludes() {\n    local excludes=\"\"\n    for pattern in \"${EXCLUDE_PATTERNS[@]}\"; do\n        excludes=\"${excludes} --exclude=${pattern}\"\n    done\n    echo \"${excludes}\"\n}\n\nrun_backup() {\n    local source_dir=\"$1\"\n    local dest_dir=\"$2\"\n    local link_ref=\"$3\"\n    local excludes\n    excludes=$(build_excludes)\n\n    local link_dest_flag=\"\"\n    if [ -n \"${link_ref}\" ] && ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \"[ -d '${link_ref}' ]\" 2>\/dev\/null; then\n        link_dest_flag=\"--link-dest=${link_ref}\"\n    fi\n\n    local source_name\n    source_name=$(basename \"${source_dir}\")\n\n    log \"INFO\" \"Backing up ${source_dir} to ${REMOTE_HOST}:${dest_dir}\/${source_name}\/\"\n\n    ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \"mkdir -p '${dest_dir}\/${source_name}'\"\n\n    # shellcheck disable=SC2086\n    rsync -avz --delete --stats \\\n        --bwlimit=\"${BANDWIDTH_LIMIT}\" \\\n        ${link_dest_flag} \\\n        ${excludes} \\\n        -e \"ssh -i ${SSH_KEY}\" \\\n        \"${source_dir}\/\" \\\n        \"${REMOTE_USER}@${REMOTE_HOST}:${dest_dir}\/${source_name}\/\" 2>&1 | tee -a \"${LOG_FILE}\"\n\n    local rc=${PIPESTATUS[0]}\n    if [ \"${rc}\" -eq 0 ]; then\n        log \"INFO\" \"Completed: ${source_dir}\"\n    elif [ \"${rc}\" -eq 24 ]; then\n        log \"WARN\" \"Completed with vanished files: ${source_dir} (exit 24, safe to ignore)\"\n    else\n        log \"ERROR\" \"Failed: ${source_dir} (exit code ${rc})\"\n        return \"${rc}\"\n    fi\n}\n\nrotate_backups() {\n    local today\n    today=$(date '+%Y%m%d')\n    local daily_dir=\"${REMOTE_DIR}\/daily\/${today}\"\n    local latest_link=\"${REMOTE_DIR}\/daily\/latest\"\n    local day_of_week\n    day_of_week=$(date '+%u')\n    local day_of_month\n    day_of_month=$(date '+%d')\n\n    log \"INFO\" \"Starting backup rotation for ${today}\"\n\n    # Determine link-dest reference (previous latest snapshot)\n    local link_ref=\"\"\n    link_ref=$(ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \\\n        \"readlink -f '${latest_link}' 2>\/dev\/null || echo ''\")\n\n    # Create today's daily directory\n    ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \"mkdir -p '${daily_dir}'\"\n\n    # Back up each source directory\n    local failed=0\n    for src in \"${BACKUP_SOURCES[@]}\"; do\n        if [ -d \"${src}\" ]; then\n            run_backup \"${src}\" \"${daily_dir}\" \"${link_ref}\" || ((failed++))\n        else\n            log \"WARN\" \"Source directory does not exist, skipping: ${src}\"\n        fi\n    done\n\n    # Update the latest symlink\n    ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \\\n        \"ln -snf '${daily_dir}' '${latest_link}'\"\n    log \"INFO\" \"Updated latest symlink to ${daily_dir}\"\n\n    # Weekly snapshot on Sundays (day 7)\n    if [ \"${day_of_week}\" -eq 7 ]; then\n        local week_label\n        week_label=$(date '+%Y-W%V')\n        local weekly_dir=\"${REMOTE_DIR}\/weekly\/${week_label}\"\n        ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \\\n            \"cp -al '${daily_dir}' '${weekly_dir}'\"\n        log \"INFO\" \"Weekly snapshot created: ${weekly_dir}\"\n    fi\n\n    # Monthly snapshot on the 1st\n    if [ \"${day_of_month}\" -eq \"01\" ]; then\n        local month_label\n        month_label=$(date '+%Y-%m')\n        local monthly_dir=\"${REMOTE_DIR}\/monthly\/${month_label}\"\n        ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \\\n            \"cp -al '${daily_dir}' '${monthly_dir}'\"\n        log \"INFO\" \"Monthly snapshot created: ${monthly_dir}\"\n    fi\n\n    # Retention cleanup\n    log \"INFO\" \"Running retention cleanup\"\n\n    # Remove daily snapshots older than retention period\n    ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \\\n        \"cd '${REMOTE_DIR}\/daily' && ls -d [0-9]* 2>\/dev\/null | sort -r | tail -n +$((RETENTION_DAILY + 1)) | xargs -r rm -rf\"\n\n    # Remove weekly snapshots older than retention period\n    ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \\\n        \"cd '${REMOTE_DIR}\/weekly' && ls -d [0-9]* 2>\/dev\/null | sort -r | tail -n +$((RETENTION_WEEKLY + 1)) | xargs -r rm -rf\"\n\n    # Remove monthly snapshots older than retention period\n    ssh -i \"${SSH_KEY}\" \"${REMOTE_USER}@${REMOTE_HOST}\" \\\n        \"cd '${REMOTE_DIR}\/monthly' && ls -d [0-9]* 2>\/dev\/null | sort -r | tail -n +$((RETENTION_MONTHLY + 1)) | xargs -r rm -rf\"\n\n    log \"INFO\" \"Retention cleanup complete\"\n\n    if [ \"${failed}\" -gt 0 ]; then\n        log \"ERROR\" \"Backup finished with ${failed} failed source(s)\"\n        return 1\n    fi\n\n    log \"INFO\" \"All backups completed successfully\"\n}\n\n# ============================================================\n# Main\n# ============================================================\n\nmkdir -p \"${LOG_DIR}\"\nlog \"INFO\" \"==========================================\"\nlog \"INFO\" \"rsync backup started\"\nlog \"INFO\" \"==========================================\"\n\nSTART_TIME=$(date +%s)\n\nrotate_backups\nRC=$?\n\nEND_TIME=$(date +%s)\nDURATION=$((END_TIME - START_TIME))\nlog \"INFO\" \"Total runtime: ${DURATION} seconds\"\n\n# Clean up old log files (keep 30 days)\nfind \"${LOG_DIR}\" -name \"backup-*.log\" -mtime +30 -delete 2>\/dev\/null || true\n\nexit ${RC}<\/code><\/pre>\n\n\n\n<p>Make the script executable:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo chmod +x \/usr\/local\/bin\/rsync-backup.sh<\/code><\/pre>\n\n\n\n<p>The <code>BACKUP_SOURCES<\/code> array controls what gets backed up. Adjust it to match your environment. Common additions include <code>\/home<\/code>, <code>\/var\/lib\/postgresql<\/code>, or application data directories. The <code>EXCLUDE_PATTERNS<\/code> array keeps cache files, temporary data, and build artifacts out of backups.<\/p>\n\n\n\n<p>Hardlinks are the key to making this space-efficient. When rsync runs with <code>--link-dest<\/code> 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 <code>cp -al<\/code> command for weekly and monthly snapshots uses the same technique.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Test the Backup Script<\/h2>\n\n\n\n<p>Run the script manually to verify everything works before setting up the timer. Execute it as root since it needs access to system directories:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo \/usr\/local\/bin\/rsync-backup.sh<\/code><\/pre>\n\n\n\n<p>The script logs to both the terminal and the log file. A successful first run looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[2026-03-30 11:42:08] [INFO] ==========================================\n[2026-03-30 11:42:08] [INFO] rsync backup started\n[2026-03-30 11:42:08] [INFO] ==========================================\n[2026-03-30 11:42:08] [INFO] Starting backup rotation for 20260330\n[2026-03-30 11:42:09] [INFO] Backing up \/etc to 10.0.1.51:\/backup\/server01\/daily\/20260330\/etc\/\nsending incremental file list\n.\/\nNetworkManager\/\nNetworkManager\/conf.d\/\n...\n\nNumber of files: 1,705 (reg: 839, dir: 232, link: 634)\nNumber of created files: 1,705 (reg: 839, dir: 232, link: 634)\nNumber of deleted files: 0\nNumber of regular files transferred: 839\nTotal file size: 12,927,418 bytes\nTotal transferred file size: 12,927,418 bytes\n\n[2026-03-30 11:42:11] [INFO] Completed: \/etc\n[2026-03-30 11:42:11] [INFO] Backing up \/srv\/data to 10.0.1.51:\/backup\/server01\/daily\/20260330\/data\/\nsending incremental file list\n.\/\n\nNumber of files: 34 (reg: 28, dir: 6)\nNumber of created files: 34 (reg: 28, dir: 6)\nNumber of regular files transferred: 28\nTotal file size: 385,201 bytes\nTotal transferred file size: 385,201 bytes\n\n[2026-03-30 11:42:12] [INFO] Completed: \/srv\/data\n[2026-03-30 11:42:12] [INFO] Updated latest symlink to \/backup\/server01\/daily\/20260330\n[2026-03-30 11:42:12] [INFO] Running retention cleanup\n[2026-03-30 11:42:12] [INFO] Retention cleanup complete\n[2026-03-30 11:42:12] [INFO] All backups completed successfully\n[2026-03-30 11:42:12] [INFO] Total runtime: 4 seconds<\/code><\/pre>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>Check the backup structure on the target server:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ssh root@10.0.1.51 \"find \/backup\/server01 -maxdepth 3 -type d | sort\"<\/code><\/pre>\n\n\n\n<p>The directory tree confirms the date-stamped snapshot with each backed-up source as a subdirectory:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/backup\/server01\n\/backup\/server01\/daily\n\/backup\/server01\/daily\/20260330\n\/backup\/server01\/daily\/20260330\/cron\n\/backup\/server01\/daily\/20260330\/data\n\/backup\/server01\/daily\/20260330\/etc\n\/backup\/server01\/daily\/20260330\/root\n\/backup\/server01\/monthly\n\/backup\/server01\/weekly<\/code><\/pre>\n\n\n\n<p>Verify the <code>latest<\/code> symlink points to today&#8217;s snapshot:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ssh root@10.0.1.51 \"ls -la \/backup\/server01\/daily\/latest\"<\/code><\/pre>\n\n\n\n<p>The symlink should resolve to the current date:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>lrwxrwxrwx. 1 root root 38 Mar 30 11:42 \/backup\/server01\/daily\/latest -> \/backup\/server01\/daily\/20260330<\/code><\/pre>\n\n\n\n<p>Check disk usage of the snapshot:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ssh root@10.0.1.51 \"du -sh \/backup\/server01\/daily\/20260330\/\"<\/code><\/pre>\n\n\n\n<p>The first full backup consumed about 13 MB in this test environment:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>13M\t\/backup\/server01\/daily\/20260330\/<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Create the Systemd Timer<\/h2>\n\n\n\n<p><a href=\"https:\/\/www.freedesktop.org\/software\/systemd\/man\/latest\/systemd.timer.html\" target=\"_blank\" rel=\"noreferrer noopener\">Systemd timers<\/a> are a better fit than cron for production backups. They integrate with journald for centralized logging, support <code>Persistent=true<\/code> 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 <a href=\"https:\/\/computingforgeeks.com\/lpic-102-scheduling-jobs-on-linux-with-cron-and-systemd-timers\/\" target=\"_blank\" rel=\"noreferrer noopener\">systemd timers<\/a>, we covered that separately.<\/p>\n\n\n\n<p>Two unit files are needed: a service unit that defines what to run, and a timer unit that defines when to run it.<\/p>\n\n\n\n<p>Create the service unit:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo vi \/etc\/systemd\/system\/rsync-backup.service<\/code><\/pre>\n\n\n\n<p>Paste this service definition:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[Unit]\nDescription=rsync backup to remote server\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=\/usr\/local\/bin\/rsync-backup.sh\nNice=10\nIOSchedulingClass=idle\nTimeoutStartSec=3600\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p>The <code>Nice=10<\/code> and <code>IOSchedulingClass=idle<\/code> settings ensure backups run at low CPU and I\/O priority, so they don&#8217;t interfere with production workloads. <code>TimeoutStartSec=3600<\/code> gives large backups up to an hour to complete before systemd kills the process.<\/p>\n\n\n\n<p>Now create the timer unit:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo vi \/etc\/systemd\/system\/rsync-backup.timer<\/code><\/pre>\n\n\n\n<p>The timer fires daily at 2 AM with a 15-minute random delay to avoid thundering herd on multi-server setups:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[Unit]\nDescription=Daily rsync backup timer\n\n[Timer]\nOnCalendar=*-*-* 02:00:00\nRandomizedDelaySec=900\nPersistent=true\n\n[Install]\nWantedBy=timers.target<\/code><\/pre>\n\n\n\n<p>Three settings matter here. <code>OnCalendar=*-*-* 02:00:00<\/code> fires at 2:00 AM daily. <code>RandomizedDelaySec=900<\/code> adds a random delay of up to 15 minutes, which prevents multiple servers from hammering the backup target simultaneously. <code>Persistent=true<\/code> is critical for production: if the server was off or asleep at 2:00 AM, the backup runs immediately after the next boot.<\/p>\n\n\n\n<p>Reload systemd and enable the timer:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo systemctl daemon-reload\nsudo systemctl enable --now rsync-backup.timer<\/code><\/pre>\n\n\n\n<p>Confirm the timer is active and shows the next scheduled run:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>systemctl list-timers rsync-backup.timer<\/code><\/pre>\n\n\n\n<p>The output shows when the next backup will trigger:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>NEXT                         LEFT       LAST PASSED UNIT               ACTIVATES\nTue 2026-03-31 02:03:33 UTC  14h left   -    -      rsync-backup.timer rsync-backup.service\n\n1 timers listed.<\/code><\/pre>\n\n\n\n<p>The random delay explains why <code>NEXT<\/code> shows 02:03 instead of exactly 02:00.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Verify a Manual Run Through Systemd<\/h3>\n\n\n\n<p>Triggering the backup through systemd (rather than running the script directly) confirms that the service unit, environment, and permissions all work correctly:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo systemctl start rsync-backup.service<\/code><\/pre>\n\n\n\n<p>Watch the progress in the journal:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo journalctl -u rsync-backup.service --no-pager -n 20<\/code><\/pre>\n\n\n\n<p>The journal output mirrors the script&#8217;s log messages:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Mar 30 11:58:02 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:02] [INFO] ==========================================\nMar 30 11:58:02 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:02] [INFO] rsync backup started\nMar 30 11:58:02 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:02] [INFO] ==========================================\nMar 30 11:58:02 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:02] [INFO] Starting backup rotation for 20260330\nMar 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:...\nMar 30 11:58:05 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:05] [INFO] Completed: \/etc\nMar 30 11:58:06 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:06] [INFO] Completed: \/srv\/data\nMar 30 11:58:06 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:06] [INFO] All backups completed successfully\nMar 30 11:58:06 source-server rsync-backup.sh[4521]: [2026-03-30 11:58:06] [INFO] Total runtime: 4 seconds\nMar 30 11:58:06 source-server systemd[1]: rsync-backup.service: Deactivated successfully.\nMar 30 11:58:06 source-server systemd[1]: Finished rsync backup to remote server.<\/code><\/pre>\n\n\n\n<p>The &#8220;Deactivated successfully&#8221; line confirms systemd is happy. If the service fails, <code>systemctl status rsync-backup.service<\/code> shows the exit code and the last log lines.<\/p>\n\n\n\n<p>For monitoring, you can also set up email or Slack alerts on failure. A simple approach is adding <code>OnFailure=notify-email@.service<\/code> to the <code>[Unit]<\/code> section of the service file, with a corresponding notification service unit.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Firewall Configuration<\/h2>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>On <strong>Rocky Linux 10<\/strong> (backup target), ensure SSH is allowed through firewalld:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo firewall-cmd --permanent --add-service=ssh\nsudo firewall-cmd --reload<\/code><\/pre>\n\n\n\n<p>Verify the rule is active:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo firewall-cmd --list-services<\/code><\/pre>\n\n\n\n<p>SSH should appear in the output:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cockpit dhcpv6-client ssh<\/code><\/pre>\n\n\n\n<p>On <strong>Ubuntu 24.04<\/strong> (backup target):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ufw allow ssh\nsudo ufw enable<\/code><\/pre>\n\n\n\n<p>Confirm the rule:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ufw status<\/code><\/pre>\n\n\n\n<p>The output confirms SSH is permitted:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Status: active\n\nTo                         Action      From\n--                         ------      ----\n22\/tcp                     ALLOW       Anywhere\n22\/tcp (v6)                ALLOW       Anywhere (v6)<\/code><\/pre>\n\n\n\n<p>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 <code>setsebool<\/code> or <code>semanage<\/code> commands are needed.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">OS Differences<\/h2>\n\n\n\n<p>The backup script and systemd units work identically on both distributions. Here are the minor differences worth knowing:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Item<\/th><th>Ubuntu 24.04<\/th><th>Rocky Linux 10<\/th><\/tr><\/thead><tbody><tr><td>rsync version<\/td><td>3.2.7<\/td><td>3.4.1<\/td><\/tr><tr><td>rsync package<\/td><td>Pre-installed<\/td><td>Pre-installed<\/td><\/tr><tr><td>Firewall<\/td><td>ufw<\/td><td>firewalld<\/td><\/tr><tr><td>SELinux<\/td><td>Not applicable (AppArmor)<\/td><td>Enforcing, no changes needed for rsync over SSH<\/td><\/tr><tr><td>Systemd unit path<\/td><td>\/etc\/systemd\/system\/<\/td><td>\/etc\/systemd\/system\/<\/td><\/tr><tr><td>Default SSH key location<\/td><td>\/root\/.ssh\/<\/td><td>\/root\/.ssh\/<\/td><\/tr><tr><td>Log rotation<\/td><td>logrotate available<\/td><td>logrotate available<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Customizing the Script<\/h2>\n\n\n\n<p>A few adjustments worth considering for production deployments.<\/p>\n\n\n\n<p><strong>Adding more backup sources<\/strong>: Edit the <code>BACKUP_SOURCES<\/code> array in the script. Database data directories (<code>\/var\/lib\/postgresql<\/code>, <code>\/var\/lib\/mysql<\/code>) should only be backed up after dumping with <code>pg_dump<\/code> or <code>mysqldump<\/code> first, because rsync cannot guarantee a consistent snapshot of a running database. If you need a complete <a href=\"https:\/\/computingforgeeks.com\/bash-script-to-automate-linux-directories-backups\/\" target=\"_blank\" rel=\"noreferrer noopener\">bash-based backup solution<\/a> with database dumps included, that&#8217;s a separate consideration.<\/p>\n\n\n\n<p><strong>Bandwidth limiting<\/strong>: The default <code>BANDWIDTH_LIMIT=5000<\/code> caps transfers at roughly 5 MB\/s. Set it to <code>0<\/code> for unlimited, or lower it on shared or metered connections. This is especially important when backing up over WAN links.<\/p>\n\n\n\n<p><strong>Retention tuning<\/strong>: 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 <code>df -h<\/code> and adjust retention values as needed.<\/p>\n\n\n\n<p><strong>Real-time synchronization<\/strong>: If you need continuous file sync rather than scheduled snapshots, <a href=\"https:\/\/computingforgeeks.com\/use-rsync-lsyncd-for-file-synchronization-on-linux\/\" target=\"_blank\" rel=\"noreferrer noopener\">rsync combined with lsyncd<\/a> watches for filesystem changes and triggers immediate transfers. That approach complements scheduled backups rather than replacing them.<\/p>\n\n\n\n<p><strong>Encryption at rest<\/strong>: For encrypted, deduplicated backups with repository-level integrity checks, <a href=\"https:\/\/computingforgeeks.com\/borgbackup-borgmatic-linux\/\" target=\"_blank\" rel=\"noreferrer noopener\">BorgBackup with borgmatic<\/a> is worth evaluating. It&#8217;s heavier than plain rsync but adds features that matter when compliance requirements exist.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping Up<\/h2>\n\n\n\n<p>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 <code>Nice<\/code> and <code>IOSchedulingClass<\/code>, and <code>Persistent=true<\/code> 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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>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. &#8230; <a title=\"Production rsync Backup with Systemd Timers on Linux\" class=\"read-more\" href=\"https:\/\/computingforgeeks.com\/rsync-backup-systemd-timer\/\" aria-label=\"Read more about Production rsync Backup with Systemd Timers on Linux\">Read more<\/a><\/p>\n","protected":false},"author":15,"featured_media":165098,"comment_status":"open","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[299,50,35910,75,663,81],"tags":[],"class_list":["post-165097","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-how-to","category-linux-tutorials","category-rocky-linux","category-security","category-storage","category-ubuntu"],"_links":{"self":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts\/165097","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/users\/15"}],"replies":[{"embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/comments?post=165097"}],"version-history":[{"count":1,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts\/165097\/revisions"}],"predecessor-version":[{"id":165109,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts\/165097\/revisions\/165109"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/media\/165098"}],"wp:attachment":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/media?parent=165097"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/categories?post=165097"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/tags?post=165097"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}