Automating rsync backups with cron on Ubuntu 24.04 is useful for application files, configuration directories, and other file-based data that can be copied consistently. On a Raff Linux VM, a safe workflow is to test rsync manually, preview destructive options with --dry-run, create versioned restore archives, prevent overlapping runs, schedule the job with cron, and verify that a restore actually works.
This tutorial deliberately separates three concepts that are often mixed together: an rsync mirror keeps a destination synchronized with the current source, a versioned archive gives you an older restore point, and an off-server copy protects against loss of the VM itself. A mirror alone is not enough for every recovery scenario, especially when --delete is enabled, because deletions at the source can be propagated to the destination.
You will build a small backup workflow for /srv/sample-app, use rsync for the current mirror, create timestamped compressed archives for retention, verify checksums, schedule both backup and health-check jobs with cron, and perform a real restore test. The example is appropriate for file-based application data. Do not copy a live database data directory with rsync and assume it is transactionally consistent; use the database's supported dump, snapshot, or backup method first.
Prerequisites:
- A Raff Linux VM running Ubuntu 24.04
- SSH access with a non-root sudo user
- Enough free disk space for the test backup and retained archives
- No public firewall port is required for the local workflow
Step 1 — Install rsync, cron, and Backup Utilities
Install the tools used by the workflow:
sudo apt update
sudo apt install -y rsync cron tar gzip coreutils util-linux
sudo systemctl enable --now cron
Check the installed tools and cron service:
rsync --version | head -n 1
tar --version | head -n 1
flock --version | head -n 1
systemctl is-active cron
Do not depend on exact package patch versions in automation; Ubuntu updates can change them while keeping command behavior compatible.
Verify: rsync, tar, and flock should report versions, and systemctl is-active cron should return active.
Step 2 — Create Safe Test Data for the Rsync Backup
Create a small application directory so every backup and restore step can be verified without touching production data:
sudo mkdir -p /srv/sample-app/config
sudo tee /srv/sample-app/app.txt > /dev/null <<'EOF'
Raff rsync backup tutorial test file
EOF
sudo tee /srv/sample-app/config/settings.env > /dev/null <<'EOF'
APP_NAME=raff-rsync-demo
APP_ENV=production
EOF
sudo chown -R root:root /srv/sample-app
sudo find /srv/sample-app -type d -exec chmod 750 {} +
sudo find /srv/sample-app -type f -exec chmod 640 {} +
List the source tree:
sudo find /srv/sample-app -maxdepth 3 -type f -print
sudo cat /srv/sample-app/app.txt
For a real application, identify what can be copied while the application is running. Configuration files and immutable assets are usually straightforward; live database files, queues, and frequently changing application state may require an application-aware backup step.
Verify: Both test files should exist and app.txt should contain Raff rsync backup tutorial test file.
Step 3 — Create Backup, Log, and Restore Directories
Create separate locations for the current rsync mirror, versioned archives, logs, and restore testing:
sudo mkdir -p \
/var/backups/raff-rsync/mirror/sample-app \
/var/backups/raff-rsync/archives \
/var/backups/raff-rsync/logs \
/var/backups/raff-rsync/restore-test
sudo chown -R root:root /var/backups/raff-rsync
sudo chmod 750 /var/backups/raff-rsync
sudo chmod 750 /var/backups/raff-rsync/{mirror,archives,logs,restore-test}
Inspect the structure:
sudo find /var/backups/raff-rsync -maxdepth 2 -type d -print
Keeping backup data outside the source tree avoids recursive copies where a backup begins copying its own previous output.
Verify: The mirror, archives, logs, and restore-test directories should exist under /var/backups/raff-rsync.
Step 4 — Test the Rsync Backup Command with --dry-run
The source path's trailing slash matters. This command copies the contents of /srv/sample-app/ into the destination directory:
sudo rsync -a --itemize-changes --dry-run \
/srv/sample-app/ \
/var/backups/raff-rsync/mirror/sample-app/
The rsync -a option is archive mode: it recursively copies files and preserves common metadata such as permissions and timestamps. It does not automatically include every metadata class such as ACLs, extended attributes, or hard links. If those matter to your application, review and test options such as -A, -X, or -H for your destination filesystem.
Now preview the behavior that will eventually keep the mirror exact:
sudo rsync -a --delete-delay --itemize-changes --dry-run \
/srv/sample-app/ \
/var/backups/raff-rsync/mirror/sample-app/
--delete and its variants remove destination files that no longer exist at the source. Rsync's own documentation recommends using --dry-run first because deletion options can be dangerous when source or destination paths are wrong.
Do not enable deletion until you have verified both paths and the dry-run output.
Verify: The dry run should show planned copies without changing the destination. Confirm that the source is /srv/sample-app/ and the destination is /var/backups/raff-rsync/mirror/sample-app/ before continuing.
Step 5 — Create a Locked Rsync Backup Script
Create /usr/local/sbin/raff-rsync-backup.sh:
sudo tee /usr/local/sbin/raff-rsync-backup.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
BACKUP_ROOT="/var/backups/raff-rsync"
SOURCE_DIR="/srv/sample-app"
MIRROR_DIR="${BACKUP_ROOT}/mirror/sample-app"
ARCHIVE_DIR="${BACKUP_ROOT}/archives"
LOG_DIR="${BACKUP_ROOT}/logs"
LOG_FILE="${LOG_DIR}/backup.log"
RETENTION_DAYS=7
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
ARCHIVE_FILE="${ARCHIVE_DIR}/sample-app-${STAMP}.tar.gz"
ARCHIVE_TMP="${ARCHIVE_FILE}.part"
mkdir -p "$MIRROR_DIR" "$ARCHIVE_DIR" "$LOG_DIR"
exec 9>/run/raff-rsync-backup.lock
if ! flock -n 9; then
printf '%s SKIP backup already running\n' "$(date -Is)" >> "$LOG_FILE"
exit 75
fi
trap 'rc=$?; printf "%s ERROR exit=%s line=%s\n" "$(date -Is)" "$rc" "$LINENO" >> "$LOG_FILE"; rm -f "$ARCHIVE_TMP"; exit "$rc"' ERR
if [ ! -d "$SOURCE_DIR" ]; then
printf '%s ERROR source missing: %s\n' "$(date -Is)" "$SOURCE_DIR" >> "$LOG_FILE"
exit 1
fi
printf '%s START source=%s\n' "$(date -Is)" "$SOURCE_DIR" >> "$LOG_FILE"
rsync -a --delete-delay --itemize-changes \
"$SOURCE_DIR"/ \
"$MIRROR_DIR"/ >> "$LOG_FILE" 2>&1
tar -C "$(dirname "$SOURCE_DIR")" \
-czf "$ARCHIVE_TMP" \
"$(basename "$SOURCE_DIR")"
gzip -t "$ARCHIVE_TMP"
mv "$ARCHIVE_TMP" "$ARCHIVE_FILE"
sha256sum "$ARCHIVE_FILE" > "${ARCHIVE_FILE}.sha256"
find "$ARCHIVE_DIR" -maxdepth 1 -type f \
\( -name 'sample-app-*.tar.gz' -o -name 'sample-app-*.tar.gz.sha256' \) \
-mmin "+$((RETENTION_DAYS * 1440))" -delete
printf '%s OK archive=%s\n' "$(date -Is)" "$ARCHIVE_FILE" >> "$LOG_FILE"
EOF
sudo chown root:root /usr/local/sbin/raff-rsync-backup.sh
sudo chmod 750 /usr/local/sbin/raff-rsync-backup.sh
The script adds several safeguards missing from many basic rsync cron examples:
set -Eeuo pipefail stops on command and pipeline failures;
flock prevents a second backup from starting while the first is still running;
- the archive is written to a
.part file before being renamed into place;
gzip -t checks the compressed archive before it is accepted;
- a SHA-256 file is written for later integrity verification;
- retention uses minutes so the seven-day threshold is explicit;
- logs contain START, OK, SKIP, or ERROR states.
Check shell syntax before executing it:
sudo bash -n /usr/local/sbin/raff-rsync-backup.sh
Verify: bash -n should exit without output or syntax errors, and the script should be owned by root with mode 750.
Step 6 — Run the First Rsync Backup Manually
Never make cron the first execution of a new backup script. Run it manually:
sudo /usr/local/sbin/raff-rsync-backup.sh
Check the log:
sudo tail -n 20 /var/backups/raff-rsync/logs/backup.log
List the current mirror and versioned archives:
sudo find /var/backups/raff-rsync/mirror/sample-app -maxdepth 3 -type f -print
sudo ls -lh /var/backups/raff-rsync/archives/
You should have both a current rsync mirror and a timestamped .tar.gz archive with a matching .sha256 file.
Verify: The log should end with an OK archive=... entry, the mirror should contain the two source files, and the archive directory should contain one .tar.gz plus its checksum file.
Step 7 — Verify Backup Integrity and Restore a File
Find the newest archive:
LATEST_ARCHIVE="$(sudo find /var/backups/raff-rsync/archives \
-maxdepth 1 -type f -name 'sample-app-*.tar.gz' \
-printf '%T@ %p\n' | sort -nr | head -n 1 | cut -d' ' -f2-)"
echo "$LATEST_ARCHIVE"
Verify its checksum:
sudo sha256sum -c "${LATEST_ARCHIVE}.sha256"
Check that tar can read the full archive index:
sudo tar -tzf "$LATEST_ARCHIVE" >/dev/null
Restore into a separate directory rather than overwriting the source:
sudo rm -rf /var/backups/raff-rsync/restore-test/*
sudo tar -xzf "$LATEST_ARCHIVE" \
-C /var/backups/raff-rsync/restore-test
sudo cat /var/backups/raff-rsync/restore-test/sample-app/app.txt
Expected content:
Raff rsync backup tutorial test file
A successful copy is not enough evidence that recovery works. A restore test is the stronger check.
Verify: sha256sum -c should report OK, tar -tzf should succeed, and the restored app.txt should contain the original text.
Step 8 — Create a Backup Health Check
Create /usr/local/sbin/raff-rsync-backup-check.sh:
sudo tee /usr/local/sbin/raff-rsync-backup-check.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
BACKUP_ROOT="/var/backups/raff-rsync"
ARCHIVE_DIR="${BACKUP_ROOT}/archives"
MIRROR_DIR="${BACKUP_ROOT}/mirror/sample-app"
MAX_AGE_MINUTES=1440
LATEST_ARCHIVE="$(find "$ARCHIVE_DIR" \
-maxdepth 1 -type f -name 'sample-app-*.tar.gz' \
-mmin -"$MAX_AGE_MINUTES" \
-printf '%T@ %p\n' | sort -nr | head -n 1 | cut -d' ' -f2-)"
if [ -z "$LATEST_ARCHIVE" ]; then
echo "FAIL no archive newer than ${MAX_AGE_MINUTES} minutes"
exit 1
fi
sha256sum -c "${LATEST_ARCHIVE}.sha256" >/dev/null
tar -tzf "$LATEST_ARCHIVE" >/dev/null
test -f "$MIRROR_DIR/app.txt"
echo "OK backup healthy: $LATEST_ARCHIVE"
EOF
sudo chown root:root /usr/local/sbin/raff-rsync-backup-check.sh
sudo chmod 750 /usr/local/sbin/raff-rsync-backup-check.sh
Test it manually:
sudo /usr/local/sbin/raff-rsync-backup-check.sh
The check confirms recency, checksum validity, archive readability, and the presence of the expected file in the rsync mirror. It does not replace a periodic restore test of the real application.
Verify: The command should print OK backup healthy: followed by the newest archive path.
Step 9 — Schedule the Rsync Backup with Cron
Create a system cron file that runs the backup daily at 02:15 and the health check at 03:00:
sudo tee /etc/cron.d/raff-rsync-backup > /dev/null <<'EOF'
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
15 2 * * * root /usr/local/sbin/raff-rsync-backup.sh
0 3 * * * root /usr/local/sbin/raff-rsync-backup-check.sh >> /var/backups/raff-rsync/logs/health.log 2>&1
EOF
sudo chown root:root /etc/cron.d/raff-rsync-backup
sudo chmod 644 /etc/cron.d/raff-rsync-backup
Files in /etc/cron.d/ use the system-crontab format, which includes the user field after the five schedule fields. Ubuntu cron monitors these files for changes, so editing the file does not normally require restarting the cron daemon.
Check the installed schedule:
sudo cat /etc/cron.d/raff-rsync-backup
sudo ls -l /etc/cron.d/raff-rsync-backup
systemctl is-active cron
Verify: The cron file should be owned by root, not group- or world-writable, contain both jobs, and the cron service should be active.
Step 10 — Verify Cron Timezone, Environment, and Logs
Cron jobs run according to the server's configured timezone. Check it before assuming 02:15 means a particular local time:
timedatectl show -p Timezone --value
date
If the schedule must align with a business timezone, either configure the server timezone intentionally or calculate the cron schedule from the server timezone. Do not assume an interactive SSH shell and cron have the same environment.
This tutorial defines an explicit PATH in /etc/cron.d/raff-rsync-backup and uses absolute script paths so the job does not depend on shell profile files.
After the scheduled time passes, inspect logs:
sudo tail -n 50 /var/backups/raff-rsync/logs/backup.log
sudo tail -n 50 /var/backups/raff-rsync/logs/health.log
sudo journalctl -u cron --since '24 hours ago' --no-pager
Cron can mail job output when a suitable mail system is configured, but minimal cloud servers often do not have one. Do not make silent cron mail your only backup-monitoring mechanism; retain logs and connect failures to your normal monitoring or alerting system in production.
Verify: Confirm the timezone is the one you expect, the backup log contains an OK for the scheduled run, and the health log contains a successful check.
Step 11 — Keep Rsync Mirrors Separate from Versioned and Off-Server Backups
An rsync mirror is useful because later runs can transfer only what changed, but a mirror follows the current source state. If a file disappears from the source and a delete option is enabled, the destination copy can disappear too.
That is why this tutorial also creates timestamped archives. For a production recovery plan, add an independent copy outside the VM as well. Options include:
If you use rsync over SSH, use key-based authentication suitable for automation, verify the remote host key, give the backup account only the access it needs, and test the exact command interactively before scheduling it.
For databases, back up through the database's supported mechanism first. Do not rsync a live PostgreSQL or MySQL data directory and call the result a verified database backup. For PostgreSQL, use a database-aware dump or backup workflow before copying the resulting backup artifact off-server.
Verify: Document where the independent copy lives and confirm that losing the source VM would not also destroy every retained recovery copy.
Step 12 — Troubleshoot Rsync and Cron Backup Failures
The rsync cron job works manually but not from cron
Check the cron service, explicit PATH, file ownership, and recent cron logs:
systemctl is-active cron
sudo cat /etc/cron.d/raff-rsync-backup
sudo ls -l /etc/cron.d/raff-rsync-backup
sudo journalctl -u cron --since '2 hours ago' --no-pager
Cron does not run your interactive shell profile, so commands that depend on aliases, shell initialization, or an interactive SSH agent often fail when scheduled.
Rsync wants to delete unexpected files
Stop and run a dry run:
sudo rsync -a --delete-delay --itemize-changes --dry-run \
/srv/sample-app/ \
/var/backups/raff-rsync/mirror/sample-app/
If the output is not exactly what you expect, correct the source/destination paths before running the real command.
The backup is skipped with exit code 75
Another run holds the lock. Check whether a backup process is legitimately still active:
ps -ef | grep '[r]aff-rsync-backup.sh'
sudo tail -n 20 /var/backups/raff-rsync/logs/backup.log
Do not remove a lock simply because a backup takes longer than usual; first determine whether the existing process is healthy or stuck.
The checksum fails
Keep the suspect archive for investigation and create a new backup rather than trusting it:
sudo /usr/local/sbin/raff-rsync-backup.sh
sudo /usr/local/sbin/raff-rsync-backup-check.sh
A checksum verifies that the archived bytes match the checksum recorded after creation. It does not prove that a changing application was captured at a transactionally consistent moment.
The disk is filling with backups
Inspect archive sizes and free space:
sudo du -sh /var/backups/raff-rsync/*
df -h /var/backups/raff-rsync
Adjust retention from measured storage usage and recovery requirements. Do not shorten retention blindly if it would remove the only recovery point you need.
Verify: After correcting the problem, run both scripts manually and confirm the backup log ends with OK and the health check passes.
Step 13 — Run the Final End-to-End Backup and Restore Test
Make a controlled change to the source:
echo 'second version' | sudo tee -a /srv/sample-app/app.txt >/dev/null
Run a fresh backup and health check:
sudo /usr/local/sbin/raff-rsync-backup.sh
sudo /usr/local/sbin/raff-rsync-backup-check.sh
Confirm the rsync mirror contains the change:
sudo tail -n 2 /var/backups/raff-rsync/mirror/sample-app/app.txt
Restore the newest archive into the test directory:
LATEST_ARCHIVE="$(sudo find /var/backups/raff-rsync/archives \
-maxdepth 1 -type f -name 'sample-app-*.tar.gz' \
-printf '%T@ %p\n' | sort -nr | head -n 1 | cut -d' ' -f2-)"
sudo rm -rf /var/backups/raff-rsync/restore-test/*
sudo tar -xzf "$LATEST_ARCHIVE" \
-C /var/backups/raff-rsync/restore-test
sudo tail -n 2 /var/backups/raff-rsync/restore-test/sample-app/app.txt
Finally, confirm the cron schedule and services:
systemctl is-active cron
sudo cat /etc/cron.d/raff-rsync-backup
sudo tail -n 10 /var/backups/raff-rsync/logs/backup.log
The workflow is complete only when the source change reaches the rsync mirror, a new archive is created, the checksum and health check pass, and the changed file can be restored from the archive.
Verify: Both the mirror and restored archive should include second version, the health check should report OK, and cron should remain active with both scheduled jobs installed.
Step 14 — Clean Up the Test Backup Workflow Safely
Use this step only if you want to remove the tutorial's test data and schedule.
First disable future runs:
sudo rm -f /etc/cron.d/raff-rsync-backup
Confirm no backup script is currently running:
ps -ef | grep '[r]aff-rsync-backup.sh' || true
Review what will be deleted:
sudo du -sh /var/backups/raff-rsync
sudo find /srv/sample-app -maxdepth 3 -type f -print
If these are only tutorial files and you have no archive you need to retain, remove them:
sudo rm -f /usr/local/sbin/raff-rsync-backup.sh
sudo rm -f /usr/local/sbin/raff-rsync-backup-check.sh
sudo rm -rf /var/backups/raff-rsync
sudo rm -rf /srv/sample-app
Do not adapt this cleanup command to a production path without separately confirming backups and retention requirements.
Verify: /etc/cron.d/raff-rsync-backup, the two tutorial scripts, /var/backups/raff-rsync, and /srv/sample-app should be absent only when you intentionally completed cleanup.
Conclusion
You automated rsync backups with cron on Ubuntu 24.04, previewed deletion behavior with --dry-run, protected the job from overlapping runs, kept a current rsync mirror, created versioned compressed archives, verified checksums, tested a restore, checked cron timezone and logs, and documented the need for an independent off-server copy.
The most important distinction is that synchronization is not automatically the same as a complete backup strategy. Rsync is excellent for efficient file copying and mirrors, but recovery planning also needs retained versions, restore testing, and a failure domain separate from the source server.
For a broader recovery design, see Cloud Server Backup Strategies. For off-server object storage, continue with Sync Files to Raff Object Storage with rclone. For database workloads, use a database-aware backup workflow before copying backup artifacts.
Sources