Skip to main content
data-recovery-headINFO

Automated backup with cron and rsync in 2026: complete Linux guide

Automate Linux backups with cron and rsync in 2026: full cron syntax, robust shell scripts with logging and alerts, rsnapshot, BorgBackup, and monitoring with Healthchecks.io. Production-ready cron + rsync recipes you can copy.

By Eric Gerard · Editor · Save My Disk13 min readPhoto via Unsplash

A bad cron rotation can overwrite archives instead of adding to them. That is one of the classic ways to lose weeks of server logs without noticing. Setting up Linux backups with cron and rsync avoids that kind of mistake. This guide shows a production-ready cron + rsync setup for 2026, with the common traps too.

The idea is simple: automation is not optional for backups. Humans forget. Cron does not. A good setup runs at 02:00 every night without anyone thinking about it. And that is just when it pays off - the day a database breaks or someone wipes a big folder of archives by mistake.

Why automate backups

The short answer: human memory is a poor scheduler for tasks you must repeat.

Humans forget, scripts do not. Backblaze's 2024 yearly survey on backup habits found that 67% of users who say they "back up regularly" do so on and off. Gaps run 2 to 6 weeks between backups. People think they are more regular than they really are. A daily cron job at 02:00 runs every time: on holidays, on weekends, and on nights with power dips (if the server has a UPS).

Incremental backup makes automation work. Without rsync and its delta transfers, copying 500 GB of data every night would be too costly. That can mean 50 to 100 GB of network transfer for a normal photo folder. With rsync, only the bytes changed since the last backup move. For a typical nightly backup to a LAN NAS, the first full copy can take a few hours. But each later run often ends in minutes, since only changed blocks cross the wire.

Scaling to more machines. Going from one to several servers turns manual backup into a long chore that never gets done right. A central cron script pulls backups from all machines to one place. It takes no human time.

Auto monitoring catches failures. A backup that has failed in silence for 3 weeks is worse than one never set up. At least then you know it does not exist. Add a Healthchecks.io ping at the end of the script. You get an email the moment any backup fails or does not run on time.

rsync in 2026: syntax and essential options

rsync is an incremental file sync tool built by Andrew Tridgell in 1996. Its delta-transfer method moves only the changed blocks of a file, not the whole file. That is the root of its speed for daily backups.

Basic syntax:

rsync -avz --delete SOURCE DESTINATION

Options explained:

  • -a (archive): preserves permissions, timestamps, symlinks, owner, group. Equivalent to -rlptgoD.
  • -v (verbose): displays transferred files.
  • -z (compress): enables compression during transfer. Useful on WAN, useless on Gigabit LAN.
  • --delete: removes files at the destination that no longer exist at the source.

Local backup to NAS:

rsync -av --delete /home/eric/ /mnt/nas/backup/eric-home/

Backup to remote server via SSH:

rsync -avz --delete -e "ssh -i /home/eric/.ssh/backup_key -p 22" \
  /var/www/ \
  backup@192.168.1.100:/data/backups/www/

Advanced options in 2026:

# --mkpath: create destination directories if absent (recent flag, rsync 3.2.3+)
rsync -av --mkpath /source/ user@host:/path/that/does/not/exist/

# --exclude: skip specific patterns
rsync -av --exclude='*.log' --exclude='tmp/' --exclude='.git/' /source/ /dest/

# --bwlimit: cap bandwidth (in KB/s)
rsync -av --bwlimit=20000 /source/ user@host:/dest/

# --checksum: force hash-based verification (slower but more reliable)
rsync -avc --checksum /source/ /dest/

# Dry run: simulate without changing anything
rsync -avn --delete /source/ /dest/

Full example: VPS backup to Hetzner Storage Box:

rsync -avz --delete \
  --exclude='*.sock' \
  --exclude='/proc' \
  --exclude='/sys' \
  --exclude='/dev' \
  --exclude='/run' \
  --bwlimit=30000 \
  -e "ssh -i /root/.ssh/hetzner_backup -p 23" \
  /etc/ /home/ /var/www/ /var/backups/ \
  u123456@u123456.your-storagebox.de:/backups/vps-main/

A script like this runs nightly. It moves only the changed data to a remote target such as a Hetzner Storage Box (2026 price: €3.81/month for 100 GB).

Cron: complete syntax and practical examples

Cron is the standard Unix task scheduler. It ships on every Linux distribution. The crontab -e command edits the current user's cron table.

The five-field syntax:

# ┌───── minute (0 - 59)
# │ ┌───── hour (0 - 23)
# │ │ ┌───── day of month (1 - 31)
# │ │ │ ┌───── month (1 - 12)
# │ │ │ │ ┌───── day of week (0 - 7, 0 and 7 = Sunday)
# │ │ │ │ │
# * * * * * command

Common schedule examples:

# Every minute (testing / monitoring)
* * * * * /usr/local/bin/monitor.sh

# Every hour at H:00
0 * * * * /usr/local/bin/hourly-backup.sh

# Every day at 02:00
0 2 * * * /usr/local/bin/daily-backup.sh

# Every Sunday at 03:00
0 3 * * 0 /usr/local/bin/weekly-backup.sh

# 1st of every month at 04:00
0 4 1 * * /usr/local/bin/monthly-backup.sh

# Every 6 hours
0 */6 * * * /usr/local/bin/incremental.sh

# Weekdays (Mon-Fri) at 08:30
30 8 * * 1-5 /usr/local/bin/workday-sync.sh

Important environment variables in crontab:

# cron does not inherit user PATH - always define it explicitly
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=admin@mydomain.com

# Explicit timezone (avoids scheduling surprises)
CRON_TZ=America/New_York

0 2 * * * /usr/local/bin/daily-backup.sh >> /var/log/backup.log 2>&1

systemd-timers: the modern alternative

On systems with systemd (Ubuntu 16.04+, Debian 9+, CentOS 7+), systemd timers are easier to trace:

# /etc/systemd/system/backup-daily.timer
[Unit]
Description=Daily rsync backup

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true  # Catches up missed jobs if server was off
Unit=backup-daily.service

[Install]
WantedBy=timers.target
# /etc/systemd/system/backup-daily.service
[Unit]
Description=Daily rsync backup
After=network.target

[Service]
Type=oneshot
User=root
ExecStart=/usr/local/bin/daily-backup.sh
Nice=19
IOSchedulingClass=idle
# Activation
systemctl enable backup-daily.timer
systemctl start backup-daily.timer
systemctl list-timers  # Check status
journalctl -u backup-daily.service  # View logs

The big win with Persistent=true: if the server was off at 02:00, the job runs on the next boot. Standard cron skips jobs when the machine is offline.

Complete backup script: logging, error handling, alerts

Rows of servers in a data center
Rows of servers in a data center

Here is a production-ready script you can adapt. It covers clear logging, error handling, and alerts.

#!/bin/bash
# /usr/local/bin/daily-backup.sh
# Daily rsync backup with logging and alerts

set -euo pipefail

# ── Configuration ──────────────────────────────────────────────────────────────
BACKUP_SOURCE="/home /etc /var/www /var/backups"
BACKUP_DEST="/mnt/nas/backups/vps-main"
LOG_FILE="/var/log/backup-daily.log"
MAX_LOG_SIZE_MB=50
LOCK_FILE="/tmp/backup-daily.lock"
HEALTHCHECK_URL="https://hc-ping.com/YOUR-UUID-HERE"  # Healthchecks.io
NOTIFY_EMAIL="admin@mydomain.com"
RSYNC_OPTIONS="-avz --delete --delete-after --exclude='*.sock' --exclude='*.pid'"
SSH_KEY="/root/.ssh/backup_key"
REMOTE_HOST="backup@192.168.1.100"
REMOTE_PATH="/data/backups"
BWLIMIT=30000  # KB/s - 30 MB/s cap

# ── Functions ──────────────────────────────────────────────────────────────────
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

send_alert() {
    local subject="$1"
    local body="$2"
    echo "$body" | mail -s "$subject" "$NOTIFY_EMAIL" 2>/dev/null || true
    # Healthchecks.io: ping /fail to signal failure
    curl -fsS --retry 3 --max-time 10 "${HEALTHCHECK_URL}/fail" \
        --data-raw "$body" > /dev/null 2>&1 || true
}

rotate_log() {
    local size_mb
    size_mb=$(du -sm "$LOG_FILE" 2>/dev/null | cut -f1 || echo 0)
    if [ "$size_mb" -gt "$MAX_LOG_SIZE_MB" ]; then
        mv "$LOG_FILE" "${LOG_FILE}.$(date +%Y%m%d)"
        gzip "${LOG_FILE}.$(date +%Y%m%d)" 2>/dev/null || true
        log "Log rotated (size threshold exceeded)"
    fi
}

cleanup() {
    rm -f "$LOCK_FILE"
}

# ── Preliminary checks ─────────────────────────────────────────────────────────
# Lock: prevent parallel executions
if [ -f "$LOCK_FILE" ]; then
    log "ERROR: backup already running (lockfile present). Aborting."
    send_alert "[BACKUP] Lock conflict on $(hostname)" \
        "Backup was already running. Check PID in $LOCK_FILE."
    exit 1
fi

trap cleanup EXIT
echo $$ > "$LOCK_FILE"

rotate_log
log "═══ Starting daily backup ═══"

# Check destination connectivity
if ! ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \
    "$REMOTE_HOST" "echo OK" > /dev/null 2>&1; then
    log "ERROR: cannot reach $REMOTE_HOST"
    send_alert "[BACKUP] Destination unreachable on $(hostname)" \
        "SSH to $REMOTE_HOST failed. Check network/key."
    exit 2
fi

# ── rsync execution ────────────────────────────────────────────────────────────
ERRORS=0
START_TIME=$(date +%s)

for SOURCE_DIR in $BACKUP_SOURCE; do
    if [ ! -d "$SOURCE_DIR" ]; then
        log "WARNING: source directory absent: $SOURCE_DIR"
        continue
    fi

    DEST_DIR="${REMOTE_PATH}/$(basename "$SOURCE_DIR")"
    log "Syncing: $SOURCE_DIR → $REMOTE_HOST:$DEST_DIR"

    if rsync $RSYNC_OPTIONS \
        --bwlimit="$BWLIMIT" \
        -e "ssh -i $SSH_KEY -o BatchMode=yes" \
        "$SOURCE_DIR/" \
        "$REMOTE_HOST:$DEST_DIR/" \
        >> "$LOG_FILE" 2>&1; then
        log "OK: $SOURCE_DIR synced"
    else
        log "ERROR: rsync failed for $SOURCE_DIR (exit code: $?)"
        ERRORS=$((ERRORS + 1))
    fi
done

# ── Final result ───────────────────────────────────────────────────────────────
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
DURATION_MIN=$((DURATION / 60))

if [ "$ERRORS" -eq 0 ]; then
    log "Backup completed successfully in ${DURATION_MIN} min"
    # Healthchecks.io success ping
    curl -fsS --retry 3 --max-time 10 "$HEALTHCHECK_URL" > /dev/null 2>&1 || true
else
    log "Backup completed with $ERRORS error(s) in ${DURATION_MIN} min"
    send_alert "[BACKUP] $ERRORS error(s) on $(hostname)" \
        "Backup finished with $ERRORS error(s). Duration: ${DURATION_MIN} min. See $LOG_FILE."
fi

log "═══ Backup complete ═══"

Add to crontab:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
0 2 * * * /usr/local/bin/daily-backup.sh

Because rsync moves only what changed, an incremental backup of a few hundred GB often takes just a few minutes per night once the first sync is done.

rsnapshot: automatic snapshot rotation

rsnapshot is an rsync wrapper. It runs snapshot rotation for you with hard links. Each snapshot looks like a full copy, but it stores only the files that are new or changed.

Installation:

apt install rsnapshot  # Debian/Ubuntu
yum install rsnapshot  # CentOS/RHEL

/etc/rsnapshot.conf configuration (excerpt):

# IMPORTANT: tabs required between fields (not spaces)
config_version  1.2

# Snapshot root directory
snapshot_root   /mnt/nas/rsnapshot/

# rsync command
cmd_rsync       /usr/bin/rsync

# Rotation intervals
retain  hourly  6    # 6 hourly snapshots
retain  daily   7    # 7 days
retain  weekly  4    # 4 weeks
retain  monthly 12   # 12 months

# Global rsync options
rsync_short_args    -az
rsync_long_args     --delete --delete-excluded --numeric-ids

# Sources to back up
backup  /home/eric/          localhost/
backup  /etc/                localhost/
backup  /var/www/            localhost/
backup  root@192.168.1.10:/home/  web-server/
backup  root@192.168.1.10:/etc/   web-server/

# Exclusions
exclude *.log
exclude tmp/
exclude .cache/

rsnapshot crontab:

# Hourly snapshots (6am - 10pm)
0 6-22 * * *    /usr/bin/rsnapshot hourly

# Daily at 02:30
30 2 * * *      /usr/bin/rsnapshot daily

# Weekly on Monday at 03:00
0 3 * * 1       /usr/bin/rsnapshot weekly

# Monthly on 1st at 04:00
0 4 1 * *       /usr/bin/rsnapshot monthly

Resulting directory structure:

/mnt/nas/rsnapshot/
├── daily.0/       ← yesterday
│   ├── localhost/
│   │   ├── home/eric/
│   │   ├── etc/
│   │   └── var/www/
│   └── web-server/
├── daily.1/       ← two days ago
├── daily.2/
...
├── weekly.0/      ← last week
├── monthly.0/     ← last month

Restoring is easy: cp -a /mnt/nas/rsnapshot/daily.2/localhost/home/eric/file.txt /home/eric/. No special tool, just a copy from a snapshot folder.

Disk space: with 180 GB of source data and a 7 daily + 4 weekly + 12 monthly = 23-snapshot rotation, I use about 320 GB on the NAS. That is 160 GB of "duplicated" data, since unchanged files share hard links across snapshots.

BorgBackup: deduplication and encryption for sensitive backups

rsync is great at file sync. BorgBackup is better when you need block-level deduplication, native encryption, and variable compression. It is my tool for offsite backups to Hetzner Storage Box and for backups that hold personal data.

rsync vs BorgBackup comparison:

CriterionrsyncBorgBackup
DeduplicationNo (hard links via rsnapshot)Yes (variable blocks ~512 KB)
At-rest encryptionNoAES-256 native
CompressionDuring transfer (-z)LZ4/ZSTD/ZLIB integrated
Single-file restoreSimple (cp from snapshot)borg extract
Disk spaceHigher (no real dedup)40-60% lower on mixed data
Setup complexityLowModerate

Installation and initialization:

apt install borgbackup  # Ubuntu 22.04: version 1.2.x

# Initialize an encrypted repository (recommended mode)
borg init --encryption=repokey-blake2 user@nas:/data/borg-repo/

# Store the passphrase in a password manager
# AND in a secure file outside the machine being backed up

Borg backup script with prune policy:

#!/bin/bash
export BORG_PASSPHRASE="YOUR_LONG_AND_COMPLEX_PASSPHRASE"
export BORG_REPO="user@nas:/data/borg-repo"

# Create snapshot with timestamp
borg create \
    --verbose \
    --compression lz4 \
    --exclude-caches \
    --exclude '/home/*/.cache' \
    --exclude '/home/*/.local/share/Trash' \
    "${BORG_REPO}::$(hostname)-$(date +%Y%m%d-%H%M%S)" \
    /home /etc /var/www

# Retention policy: 7 daily + 4 weekly + 12 monthly
borg prune \
    --verbose \
    --list \
    --keep-daily=7 \
    --keep-weekly=4 \
    --keep-monthly=12 \
    "${BORG_REPO}"

# Integrity verification (recommended weekly)
# borg check "${BORG_REPO}"

Restoration:

# List archives
borg list "${BORG_REPO}"

# Restore a specific file
borg extract "${BORG_REPO}::server-20260608-020000" home/eric/documents/important.pdf

# Full restoration
borg extract "${BORG_REPO}::server-20260608-020000"

When to prefer Borg over rsync:

  • Sensitive data (personal documents, databases containing PII)
  • Backups to cloud storage or untrusted hosts
  • Data volumes with high redundancy (photos, source code with git history)
  • Need for a compressed snapshot history on limited storage

Take a 100 GB remote target such as a Hetzner Storage Box. Borg can keep several months of deduplicated, compressed snapshots there. It uses a fraction of the space plain rsync copies would need for the same history depth. The ratio sits well below 1, based on how redundant the source data is.

Monitoring and alerting: never assume the backup is running

A backup that has failed in silence for 3 weeks is a disaster waiting to happen. Monitoring is not optional.

Healthchecks.io: the simplest approach

Healthchecks.io is a cron monitoring service based on pings. Create a check with the expected interval (daily 24h + 1h grace). Then add the ping at the end of your script. If the ping does not arrive, you get an email.

# At the end of the backup script, on success:
curl -fsS --retry 3 --max-time 10 \
    "https://hc-ping.com/YOUR-UUID" > /dev/null 2>&1 || true

# On failure, ping the /fail endpoint:
curl -fsS --retry 3 --max-time 10 \
    "https://hc-ping.com/YOUR-UUID/fail" > /dev/null 2>&1 || true

Free plan: 20 checks. That is enough for 4 servers with daily + weekly monitoring. Business plan at $20/year for teams.

Log monitoring with simple grep:

# Crontab: check backup logs for errors every hour
0 * * * * grep -i "error\|fail" /var/log/backup-daily.log \
    | tail -5 \
    | mail -s "[$(hostname)] Backup errors" admin@mydomain.com 2>/dev/null || true

Backup freshness verification script:

#!/bin/bash
# Verify that the most recent backup is less than 26 hours old
BACKUP_DIR="/mnt/nas/backups"
MAX_AGE_HOURS=26

find "$BACKUP_DIR" -name "*.log" -newer \
    <(date -d "$MAX_AGE_HOURS hours ago") > /tmp/recent_backups 2>/dev/null

if [ ! -s /tmp/recent_backups ]; then
    echo "ALERT: No recent backup on $(hostname)" \
    | mail -s "[BACKUP] Backup too old!" admin@mydomain.com
fi

Prometheus + Grafana for advanced homelabs:

For setups with many servers, the Prometheus node_exporter exposes filesystem metrics. An Alertmanager rule can fire a Slack alert if the last backup timestamp goes past a set limit:

# prometheus/rules/backup.yml
groups:
  - name: backup_freshness
    rules:
      - alert: BackupStaleness
        expr: |
          (time() - node_filesystem_file_content_mtime_seconds{
            mountpoint="/mnt/nas",
            path="/data/backups/vps-main"
          }) > 90000
        for: 1h
        labels:
          severity: warning
        annotations:
          summary: "Stale backup on {{ $labels.instance }}"
          description: "Last backup {{ $value | humanizeDuration }} ago"

A dashboard like this can run on hardware as small as a Raspberry Pi. It gives you central monitoring for several servers at near-zero cost (Prometheus and Grafana are both open source).


Cron + rsync automation is the layer that makes the 3-2-1 backup strategy work at scale. To see how this fits a full backup setup, read the 3-2-1 backup strategy guide. For Windows and Mac users who also run Linux servers, the automatic backup Windows and Mac guide covers the GUI tools.

When prevention fails and you need data recovery, the hard drive data recovery guide 2026 covers DIY and pro options. The best data recovery software 2026 comparison tests the tools out there. For pro recovery cost estimates, see our data recovery cost guide 2026.

Editorial pick
4.5 / 5

Automated GUI backup for Windows and Mac

If you also manage Windows/Mac machines alongside your Linux servers - EaseUS Todo Backup automates backups without any command line · Free version available

Founded in 200430-day guaranteeFree 2 GB version
See the offer
Editorial pick
4.5 / 5

Recover your deleted files → EaseUS

Free scan · deleted, formatted & lost files · Windows & Mac

Founded in 200430-day guaranteeFree 2 GB version
See the offer

Frequently asked questions

Cron vs systemd-timers: which should I use for automated backups?

Both work well in production. Cron is everywhere. It has shipped on every Linux distribution for 40 years, and every sysadmin knows its syntax. systemd-timers tie in better with journalctl. They also handle missed jobs (if the server was off at the set time) and resource limits via cgroups. On Ubuntu 22.04 or Debian 12, both run side by side with no conflict. My pick: cron for simple backup scripts, and systemd-timers for critical jobs where journalctl traceability matters.

Is rsync over SSH secure for sending backups to a remote VPS?

Yes, as long as you use SSH keys (RSA or Ed25519 - never passwords). Turn off password login in /etc/ssh/sshd_config. Make a dedicated SSH account for backups with limited access (authorized_keys with the command= option to allow only rsync commands). The rsync-over-SSH transfer is encrypted with AES-256 through the SSH channel. A good add-on: set up fail2ban on the destination server to block brute-force tries.

Is rsync compression (-z flag) still useful in 2026?

It depends. rsync compression (-z) helps on slow links (WAN, low upload) for text, XML, and log files. It can cut transfer volume by 60 to 80%. It hurts on files that are already compressed (JPEG images, ZIP/tar.gz archives, MP4 videos). It also wastes CPU on Gigabit LAN links. On a home NAS over Gigabit, -z is mostly not worth it. For backups to Backblaze B2 via rclone, turning on compression for /var/log and /etc is a sound default.

How do I limit rsync bandwidth to avoid saturating the network?

The --bwlimit=KBPS option caps rsync throughput. For example, --bwlimit=10000 limits it to 10 MB/s. For nightly backups to a remote server, a cap of 20 MB/s (--bwlimit=20000) is a good way to keep bandwidth free. Pair it with nice -n 19 and ionice -c 3 to lower CPU and I/O priority. Then the backup script runs in the background without hurting live services.

How do I encrypt rsync backups at rest?

rsync itself does not encrypt at rest. It encrypts in transit via SSH. For at-rest encryption, you have three options. (1) BorgBackup with --encryption=repokey-blake2, which builds in deduplication + AES-256 encryption + compression. (2) Restic with an encrypted repo to Backblaze B2 or any S3-compatible backend. (3) EncFS or gocryptfs on the destination folder (less advised, since it adds work). For offsite backups that hold sensitive data, I switch to BorgBackup or Restic.

What is the difference between rsync --delete, --delete-before, --delete-during, and --delete-after?

--delete (default: --delete-during) removes files at the destination that no longer exist at the source, during the transfer. --delete-before deletes first, before it transfers (handy when space is tight). --delete-after deletes only after the full transfer ends. That guards against lost files if rsync errors mid-transfer. For backups, I suggest --delete-after for the most safety. If rsync stops partway, the destination stays whole with the old files intact.

Is rsnapshot still maintained in 2026?

Yes. The rsnapshot project is still kept up on GitHub (latest release 1.4.5, 2023). It stays useful as an rsync wrapper with auto snapshot rotation (hourly/daily/weekly/monthly). Its main limit is the lack of native block deduplication. Each snapshot stores a full copy of unchanged files via hard links. That saves inodes, but not real blocks when files change a lot. For backing up config files and source code, rsnapshot is great. For large databases or binary files that change often, BorgBackup does better.

How do I back up a MySQL/PostgreSQL database with rsync?

Never copy the data files straight while the server runs. MySQL/PostgreSQL data files in use are not consistent when copied live. Here is the right way. (1) Dump the database with mysqldump or pg_dump to a .sql.gz file. (2) Copy that dump with rsync. Full command: pg_dump -U postgres mydb | gzip > /backup/mydb-$(date +%Y%m%d).sql.gz then rsync -avz /backup/ user@nas:/data/backups/. The gzip dump shrinks 70 to 90%, based on the content.