The Data Protection Review
// Section
Linux-Backups
// Published
May 22, 2019
// Read time
8 min
// Share
LinkedIn
Linux-Backups

Writing a Reliable rsync Backup Script (With Examples)

Learn to build a production-ready rsync backup script: hardlink incrementals, SSH targets, exclude rules, logging, and cron scheduling.

The editors of The Data Protection Review
Editorial Staff
Abstract visualization of data blocks syncing between two terminal windows over an encrypted network channel
Abstract visualization of data blocks syncing between two terminal windows over an encrypted network channel

Most Linux administrators write their first backup script the same way: a single rsync -av /source /destination line dropped into a cron job, run once, and forgotten. It works right up until a drive fills up, a network path drops mid-transfer, or someone needs a restore point from three Tuesdays ago and discovers every backup silently overwrote the last one. rsync is one of the most dependable data-movement tools in the Unix toolbox, but a backup script built on it only becomes dependable when someone accounts for the failure modes rsync itself doesn’t handle.

This guide walks through the flags that matter, a hardlink-based incremental pattern that gives you multiple restore points without multiplying your storage bill, remote transport over SSH, exclude handling, logging and exit-code checking, and a realistic cron scheduling pattern. It closes with an honest look at where a hand-rolled rsync script stops being the right tool.

Why rsync Is a Reasonable Backup Foundation

rsync’s core value is its delta-transfer algorithm: it compares source and destination and moves only the changed blocks of a file, not the whole file. For a nightly backup of a large file tree — web application data, home directories, configuration repositories — that difference is the gap between a job that finishes in minutes and one that saturates a network link for hours.

It is also nearly universal. rsync ships in every mainstream Linux distribution’s default repositories, has decades of production hardening behind it, and requires no server-side agent beyond an SSH daemon and rsync itself installed on the remote host. For single-host, filesystem-level backups, that combination of efficiency and minimal dependencies is hard to beat with a shell script.

None of that makes it a complete backup system out of the box. rsync mirrors or copies; it does not version, encrypt, deduplicate, or catalog on its own. Everything past a basic mirror is something the script author has to build.

Core Flags Every Backup Script Should Use

A handful of flags separate a backup-grade rsync invocation from a casual file copy.

  • -a (archive mode) bundles recursion, and preservation of symlinks, permissions, timestamps, group and owner, and device files. It is the baseline for any backup where metadata matters, which is nearly always.
  • --delete removes files from the destination that no longer exist at the source, keeping a mirror-style backup from silently growing with stale data. Pair it with --delete-excluded if your exclude list should also prune previously-copied files.
  • -e ssh (or -e "ssh -i /path/to/key -p 2222" for a custom key or port) routes the transfer through an encrypted SSH tunnel when the destination is remote. Without it, rsync’s native daemon protocol on port 873 is unauthenticated and unencrypted by default — not something to expose across an untrusted network.
  • --link-dest=DIR is the flag that turns rsync into an incremental backup tool. It tells rsync to hardlink any unchanged file from a reference directory (typically yesterday’s backup) instead of copying it again, so each backup directory looks like a full backup but consumes disk space only for what actually changed.
  • -z enables compression during transfer, worth adding on bandwidth-constrained links and generally not worth it on a fast LAN where CPU becomes the bottleneck instead.
  • --stats and -v/--itemize-changes give you transfer summaries and per-file change detail, which matter enormously once you’re troubleshooting why a backup job ran long or missed files.

The --link-dest pattern is worth understanding in some depth because it’s the mechanism that makes rsync-based incrementals viable at all, and it maps conceptually to the difference between incremental vs differential backup approaches.

Each backup run creates a new, dated destination directory. rsync copies only the files that changed since the reference snapshot (usually the most recent prior backup) and creates a filesystem hardlink — not a copy — for every unchanged file, pointing at the identical inode in the reference directory. The result: browsing any single day’s backup directory shows the complete file tree as it existed that day, but unchanged files across days consume no additional disk space, because they’re all pointing at the same underlying data blocks.

This gives you full-backup convenience (every snapshot is independently browsable and restorable) with incremental-backup economics (storage scales with the rate of change, not the number of snapshots). The tradeoff is that hardlinks only work within a single filesystem — you can’t --link-dest across separate mounted volumes or network shares that don’t share the same underlying filesystem, and the destination filesystem must support hardlinks (most Linux-native filesystems do; some network and legacy filesystems do not).

A Realistic Incremental Backup Script

The script below implements the pattern described above: dated snapshot directories, hardlinked unchanged files, an exclude list, logging, and basic error handling via rsync’s exit code.

#!/usr/bin/env bash
#
# incremental-rsync-backup.sh
# Hardlink-based incremental backup over SSH with logging and exit-code checks.

set -uo pipefail

SOURCE="/var/www/app-data/"
REMOTE_USER="backup"
REMOTE_HOST="backup-target.internal"
REMOTE_BASE="/srv/backups/app-data"
SSH_KEY="/home/backup/.ssh/id_backup"
LOG_DIR="/var/log/rsync-backup"
DATE_STAMP="$(date +%Y-%m-%d_%H%M)"
DEST="${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_BASE}/${DATE_STAMP}"
LATEST_LINK="${REMOTE_BASE}/latest"
LOG_FILE="${LOG_DIR}/backup-${DATE_STAMP}.log"

mkdir -p "$LOG_DIR"

rsync -az --delete \
  -e "ssh -i ${SSH_KEY}" \
  --link-dest="${LATEST_LINK}" \
  --exclude='*.tmp' \
  --exclude='cache/' \
  --exclude='node_modules/' \
  --exclude='.git/' \
  --log-file="${LOG_FILE}" \
  "${SOURCE}" "${DEST}"

RSYNC_EXIT=$?

if [ "$RSYNC_EXIT" -eq 0 ]; then
  ssh -i "${SSH_KEY}" "${REMOTE_USER}@${REMOTE_HOST}" \
    "rm -f ${LATEST_LINK} && ln -s ${REMOTE_BASE}/${DATE_STAMP} ${LATEST_LINK}"
  echo "$(date): backup completed successfully, exit code ${RSYNC_EXIT}" >> "${LOG_FILE}"
else
  echo "$(date): backup FAILED, rsync exit code ${RSYNC_EXIT}" >> "${LOG_FILE}"
  # Non-zero exit lets cron's mail-on-failure or a monitoring check catch this.
  exit "${RSYNC_EXIT}"
fi

A few details worth calling out: --link-dest points at a latest symlink rather than a hardcoded prior date, so the script doesn’t need to calculate yesterday’s directory name. The latest symlink is only repointed after rsync exits successfully, so a failed run never gets promoted as the new reference point for the next incremental. And set -uo pipefail catches unset variables and pipeline failures — deliberately omitting -e here since rsync’s own exit code is checked explicitly instead of letting the script die silently mid-transfer.

Exclude Patterns and Common Pitfalls

Exclude rules are where rsync scripts most often go wrong. A few practical notes:

  • Trailing slashes matter. --exclude='cache/' excludes a directory named cache anywhere in the tree; --exclude='cache' will also match a file named cache.
  • Exclude rules are evaluated in order, and rsync stops at the first match — so a broad --include meant to carve out an exception from an earlier --exclude has to appear before the exclude that would otherwise catch it.
  • For long exclude lists, --exclude-from=/path/to/exclude-list.txt is more maintainable than a wall of inline flags, and keeps the list itself under version control alongside the script.
  • Test exclude behavior with --dry-run -v before trusting it against production data. It’s cheap insurance against discovering, mid-restore, that an entire directory was silently skipped for months.

Logging, Error Handling, and Alerting

A backup script that runs unattended needs to make its own failures visible, because nobody is watching a cron job execute at 2 a.m. Beyond rsync’s own --log-file, wrap the invocation so that a non-zero exit code triggers something a human will actually see — an email via cron’s built-in MAILTO, a message to a monitoring system, or, at minimum, a distinct exit code and log line that a separate health-check script scans for.

It’s also worth checking disk space on the destination before a run and logging free space after, since a hardlink-based incremental scheme can still consume the entire destination volume if enough source data changes between runs, or if --link-dest isn’t being honored because the destination filesystem doesn’t support hardlinks the way you expect.

Scheduling With Cron

A typical nightly schedule, added via crontab -e for the backup user:

# Run incremental backup nightly at 1:15 a.m.
15 1 * * * /usr/local/bin/incremental-rsync-backup.sh >> /var/log/rsync-backup/cron.log 2>&1

Redirecting both stdout and stderr into a cron log file, separate from rsync’s own --log-file, catches script-level errors (a missing SSH key, an unreachable host) that occur before or after rsync itself runs. For environments with several source directories, a wrapper script that loops over a list of source/destination pairs — each still using its own dated --link-dest snapshot directory — keeps a single cron entry manageable instead of proliferating one job per directory.

Limitations and When to Graduate to a Dedicated Tool

An rsync-based script handles single-host, filesystem-level backup well, but it has real boundaries:

  • No built-in encryption at rest. SSH encrypts data in transit, but files land on the destination exactly as they exist at the source. If the destination needs encryption at rest, that has to come from the destination filesystem, a disk-level encryption layer, or a wrapper that encrypts before or during transfer.
  • No deduplication beyond hardlinks. The --link-dest pattern saves space only when a file is byte-identical to its prior version; it doesn’t deduplicate across different files with shared content the way a block-level or content-addressed backup tool does.
  • Single host, single script instance at a time. rsync backs up one source to one destination per invocation. Coordinating backups across a fleet of servers means building that orchestration yourself — scheduling, tracking success/failure per host, and centralizing logs — none of which rsync provides natively.
  • No built-in retention/pruning policy. The script above will happily create a new dated directory every night forever. A pruning step (or a separate cleanup script) that removes snapshots past a retention window is a second piece of logic to write and maintain.
  • No application awareness. rsync copies files; it has no concept of a database’s internal consistency state, an application’s quiesce hooks, or a VM snapshot boundary.

These aren’t flaws in rsync — they reflect what it was built to do. When a backup need grows to include multi-host fleets, retention policy enforcement, deduplication at scale, encrypted-at-rest storage, or application-consistent snapshots of live databases, that’s usually the point to evaluate a dedicated backup platform rather than extending a shell script further. It’s also worth reviewing the underlying strategy independent of tooling — the 3-2-1 backup rule and a clear sense of RPO vs RTO for the data in question should drive that decision more than any single tool’s feature list. For a broader survey of what else is available in this space, see our overview of Linux backup command-line tools.

CISA’s guidance for ransomware preparedness echoes several of the same fundamentals independent of tooling: maintain offline or otherwise isolated backup copies and regularly test restore procedures rather than assuming a backup job’s success implies a working restore path (CISA #StopRansomware Guide).

Frequently Asked Questions

Does rsync encrypt backups?

Not on its own. Running rsync over -e ssh encrypts data in transit between source and destination, but files are written to the destination unencrypted unless the destination filesystem, disk, or a separate encryption layer handles encryption at rest. rsync has no native at-rest encryption feature.

Use --link-dest=/path/to/previous/backup alongside a new, uniquely named destination directory for each run. rsync copies only changed files and hardlinks everything unchanged from the reference directory, producing a full-looking snapshot that only consumes disk space for actual changes.

Is rsync safe for backing up a live database?

Not reliably on its own. rsync copies files at the filesystem level with no awareness of a database engine’s internal consistency state, so a database file mid-write can be copied in an inconsistent state. Use the database’s native dump/export tools, or quiesce/snapshot it first, then rsync the resulting consistent files.

When should I stop using a custom rsync script and switch to a dedicated backup tool?

Once requirements grow to include multi-host orchestration, centralized retention and pruning policy, deduplication beyond simple hardlinks, encryption at rest, or application-consistent backups of live databases, a purpose-built backup platform generally handles those requirements more reliably than an extended shell script.

What’s the difference between --delete and the default rsync behavior?

By default, rsync only adds and updates files at the destination — it never removes files that no longer exist at the source, so a destination can accumulate stale files indefinitely. Adding --delete makes the destination a true mirror by removing files there that have been deleted at the source.