Bash Backup Script with rsync: Incremental Backups, Logging and Cron

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
rsync 3.4.4-1.el10_2
coreutils 9.5-8.el10_2 (realpath)
findutils 4.10.0-5.el10 (find)
util-linux 2.40.2 (flock)
Applies to Linux distributions with Bash installed
Privilege Normal user for lab paths under /tmp; production paths may need write access to backup storage
Scope Bash rsync snapshot backups for a specific directory tree on a local repository — not SOURCE=/ or remote REPO=user@host: paths. Covers validation, --dry-run, --link-dest incrementals, .incomplete snapshots, flock, logging with rsync diagnostics, retention by .complete age, cron scheduling, and restore verification.
Related guides Shell scripting course hub
Short on time?Beta Get the gist of this 13-minute read in seconds, then ask follow-up questions

This Bash script creates timestamped backups with rsync, reuses unchanged files with --link-dest, prevents overlapping runs, rotates old snapshots, and keeps a log of each backup. Each snapshot remains a normal directory, so restoring a file is a copy or rsync operation.

You'll also restore files from a snapshot and verify their contents before putting the backup on a cron schedule. Commands below were run under /tmp/bash-backup-lab/; use your own absolute paths in production.


Bash Backup Script with rsync

Save the script as /usr/local/bin/backup.sh (or /tmp/bash-backup-lab/backup.sh while testing):

bash
#!/usr/bin/env bash
set -euo pipefail

SOURCE="${SOURCE:-/tmp/bash-backup-lab/src/}"
REPO="${REPO:-/tmp/bash-backup-lab/repo}"
LOG_DIR="${LOG_DIR:-/tmp/bash-backup-lab/logs}"
LOCK_FILE="${LOCK_FILE:-/tmp/bash-backup-lab/run/backup.lock}"
DRY_RUN="${DRY_RUN:-0}"
RETENTION_DAYS="${RETENTION_DAYS:-7}"

SNAP_PREFIX="snap-"
INCOMPLETE_SUFFIX=".incomplete"
COMPLETE_MARKER=".complete"
INCOMPLETE_DIR=""
LOG_FILE=""

log() {
  local line
  line="$(date '+%F %T') $*"
  if [[ -n ${LOG_FILE:-} && -d ${LOG_DIR:-/nonexistent} ]]; then
    printf '%s\n' "$line" | tee -a "$LOG_FILE" >&2
  else
    printf '%s\n' "$line" >&2
  fi
}

validate_settings() {
  case "$DRY_RUN" in
    0|1) ;;
    *)
      log "ERROR: DRY_RUN must be 0 or 1"
      exit 1
      ;;
  esac
  [[ $RETENTION_DAYS =~ ^[0-9]+$ ]] || {
    log "ERROR: RETENTION_DAYS must be a non-negative integer"
    exit 1
  }
}

validate_paths() {
  local src_real repo_real

  mkdir -p "$LOG_DIR"

  [[ -d $SOURCE ]] || { log "ERROR: source directory missing: $SOURCE"; exit 1; }
  [[ -n $REPO ]] || { log "ERROR: REPO is empty"; exit 1; }
  case $SOURCE in
    /*) ;;
    *) log "ERROR: SOURCE must be an absolute path: $SOURCE"; exit 1 ;;
  esac
  case $REPO in
    /*) ;;
    *) log "ERROR: REPO must be an absolute path: $REPO"; exit 1 ;;
  esac

  src_real=$(realpath "$SOURCE")
  repo_real=$(realpath -m "$REPO")

  if [[ $src_real == / ]]; then
    log "ERROR: SOURCE=/ is not supported by this script"
    exit 1
  fi
  if [[ $repo_real == / ]]; then
    log "ERROR: REPO=/ is not allowed"
    exit 1
  fi

  if [[ $src_real == "$repo_real" ]]; then
    log "ERROR: SOURCE and REPO must not be the same path"
    exit 1
  fi
  if [[ $repo_real == "$src_real"/* ]]; then
    log "ERROR: REPO must not be inside SOURCE ($repo_real inside $src_real)"
    exit 1
  fi
  if [[ $src_real == "$repo_real"/* ]]; then
    log "ERROR: SOURCE must not be inside REPO ($src_real inside $repo_real)"
    exit 1
  fi

  mkdir -p "$REPO"
}

latest_complete_snapshot() {
  local -a snaps=()
  local d

  shopt -s nullglob
  snaps=("${REPO}/${SNAP_PREFIX}"*)
  shopt -u nullglob
  ((${#snaps[@]})) || return 1

  mapfile -t snaps < <(printf '%s\n' "${snaps[@]}" | LC_ALL=C sort -r)
  for d in "${snaps[@]}"; do
    [[ -f ${d}/${COMPLETE_MARKER} ]] || continue
    [[ ${d} == *${INCOMPLETE_SUFFIX} ]] && continue
    printf '%s\n' "$d"
    return 0
  done
  return 1
}

cleanup_incomplete() {
  [[ -n ${INCOMPLETE_DIR:-} && -d $INCOMPLETE_DIR ]] || return 0
  log "cleanup: removing incomplete snapshot $INCOMPLETE_DIR"
  rm -rf -- "$INCOMPLETE_DIR"
}

rotate_old_snapshots() {
  local marker parent base

  while IFS= read -r -d '' marker; do
    parent=$(dirname "$marker")
    base=$(basename "$parent")
    case $base in
      ${SNAP_PREFIX}*) ;;
      *) continue ;;
    esac
    [[ $base == *${INCOMPLETE_SUFFIX} ]] && continue
    log "retention: removing $parent"
    rm -rf -- "$parent"
  done < <(
    find "$REPO" -mindepth 2 -maxdepth 2 -type f -name "$COMPLETE_MARKER" -mtime +"$RETENTION_DAYS" -print0
  )
}

run_backup() {
  local ts prev final_dir link_dest=() rsync_opts=(-a)
  ts=$(date '+%Y%m%d-%H%M%S')
  LOG_FILE="${LOG_DIR}/backup-${ts}.log"

  validate_settings
  validate_paths

  final_dir="${REPO}/${SNAP_PREFIX}${ts}"
  INCOMPLETE_DIR="${final_dir}${INCOMPLETE_SUFFIX}"

  if [[ -e $final_dir || -e $INCOMPLETE_DIR ]]; then
    log "ERROR: snapshot already exists for timestamp $ts"
    exit 1
  fi

  if df -Pk "$REPO" >/dev/null 2>&1; then
    log "preflight: $(df -Pk "$REPO" | awk 'NR==2 {print $4}') KiB free on backup filesystem (advisory only)"
  fi

  if prev=$(latest_complete_snapshot); then
    link_dest=(--link-dest="$prev")
    log "link-dest: $prev"
  else
    log "link-dest: none (first snapshot)"
  fi

  [[ $DRY_RUN == 1 ]] && rsync_opts+=(--dry-run)

  log "rsync start: $SOURCE -> $INCOMPLETE_DIR"
  mkdir -p "$INCOMPLETE_DIR"
  rsync "${rsync_opts[@]}" "${link_dest[@]}" "$SOURCE" "$INCOMPLETE_DIR/" 2>&1 | tee -a "$LOG_FILE"

  [[ $DRY_RUN == 1 ]] && { log "dry-run complete"; return 0; }

  : > "${INCOMPLETE_DIR}/${COMPLETE_MARKER}"
  mv "$INCOMPLETE_DIR" "$final_dir"
  INCOMPLETE_DIR=""
  log "snapshot complete: $final_dir"
  rotate_old_snapshots
}

main() {
  mkdir -p "$(dirname "$LOCK_FILE")"
  exec 9>"$LOCK_FILE"
  if ! flock -n 9; then
    echo "another backup is already running" >&2
    exit 75
  fi
  trap cleanup_incomplete EXIT
  run_backup
}

main "$@"

Each run creates a new snap-YYYYMMDD-HHMMSS/ directory under REPO. Unchanged files are hard-linked from the previous snapshot. Until rsync finishes successfully, the new backup keeps the .incomplete suffix; the EXIT trap removes it if the run fails.

Confirm the tools are installed before you edit paths:

bash
command -v bash rsync flock realpath find
output
/usr/bin/bash
/usr/bin/rsync
/usr/bin/flock
/usr/bin/realpath
/usr/bin/find

This script backs up a specific directory tree, not an entire root filesystem. For databases such as MySQL or PostgreSQL, copying live data files may not produce a consistent backup. Use the database's backup or dump tools, or a storage snapshot designed for that application.


Configure the Backup Script

Set these variables at the top of the script or export them before calling it:

Variable Purpose
SOURCE Directory to back up — use a trailing slash so contents land at the snapshot root
REPO Local snapshot repository (absolute path)
LOG_DIR Per-run log files
LOCK_FILE flock lock file path
RETENTION_DAYS Delete snapshots whose .complete marker is older than this many days
DRY_RUN 0 or 1 only — set to 1 for rsync --dry-run without promoting a snapshot

Both SOURCE and REPO must be absolute paths so cron jobs do not depend on the working directory. The script checks nesting with realpath and realpath -m before creating REPO, so an invalid path such as REPO=/data/backups under SOURCE=/data/ is rejected without creating the nested directory.

This script expects REPO to be a local filesystem path, so REPO=user@host:/path will not work. The script needs local filesystem access because it finds old snapshots, renames completed backups, and removes expired ones locally. For a remote server, mount the storage locally or use a separate rsync over SSH workflow.

SOURCE=/ and REPO=/ are rejected. The script is intended to back up a specific directory tree, not an entire root filesystem.

For lab testing, create sample data once:

bash
mkdir -p /tmp/bash-backup-lab/{src,repo,logs,restore}
printf 'version=1\n' > /tmp/bash-backup-lab/src/notes.txt
mkdir -p /tmp/bash-backup-lab/src/config
printf 'listen=8080\n' > /tmp/bash-backup-lab/src/config/app.ini
chmod +x /tmp/bash-backup-lab/backup.sh

A nested repository is rejected before rsync starts:

bash
SOURCE=/tmp/bash-backup-lab/src/ REPO=/tmp/bash-backup-lab/src/backups /tmp/bash-backup-lab/backup.sh
output
2026-09-12 11:26:36 ERROR: REPO must not be inside SOURCE (/tmp/bash-backup-lab/src/backups inside /tmp/bash-backup-lab/src)

Test the Backup with --dry-run

Run a dry pass before the first real backup on a new host or after changing paths:

bash
DRY_RUN=1 /tmp/bash-backup-lab/backup.sh
output
2026-09-12 11:17:41 preflight: 9721772 KiB free on backup filesystem (advisory only)
2026-09-12 11:17:41 link-dest: none (first snapshot)
2026-09-12 11:17:41 rsync start: /tmp/bash-backup-lab/src/ -> /tmp/bash-backup-lab/repo/snap-20260912-111741.incomplete
2026-09-12 11:17:41 dry-run complete
2026-09-12 11:17:41 cleanup: removing incomplete snapshot /tmp/bash-backup-lab/repo/snap-20260912-111741.incomplete

In dry-run mode, the script creates the temporary snapshot directory so the normal path handling can be tested, but rsync --dry-run does not copy files. The EXIT trap removes the empty .incomplete directory afterward. No .complete file is written.


Run the First Backup

Run the script without DRY_RUN:

bash
/tmp/bash-backup-lab/backup.sh
output
2026-09-12 11:17:41 preflight: 9721740 KiB free on backup filesystem (advisory only)
2026-09-12 11:17:41 link-dest: none (first snapshot)
2026-09-12 11:17:41 rsync start: /tmp/bash-backup-lab/src/ -> /tmp/bash-backup-lab/repo/snap-20260912-111741.incomplete
2026-09-12 11:17:42 snapshot complete: /tmp/bash-backup-lab/repo/snap-20260912-111741

List the new snapshot — a .complete marker and mirrored files should be present:

bash
find /tmp/bash-backup-lab/repo/snap-20260912-111741 -type f | sort
output
/tmp/bash-backup-lab/repo/snap-20260912-111741/.complete
/tmp/bash-backup-lab/repo/snap-20260912-111741/config/app.ini
/tmp/bash-backup-lab/repo/snap-20260912-111741/notes.txt

How Incremental rsync Snapshots Work

Each snapshot looks like a complete copy of the source. To save space, --link-dest hard-links unchanged files to the previous snapshot while changed or new files get their own data. --link-dest points at the last directory that contains .complete.

Change one source file and run a second backup:

bash
echo 'version=2 changed' > /tmp/bash-backup-lab/src/notes.txt
/tmp/bash-backup-lab/backup.sh
output
2026-09-12 11:17:42 preflight: 9721640 KiB free on backup filesystem (advisory only)
2026-09-12 11:17:42 link-dest: /tmp/bash-backup-lab/repo/snap-20260912-111741
2026-09-12 11:17:42 rsync start: /tmp/bash-backup-lab/src/ -> /tmp/bash-backup-lab/repo/snap-20260912-111742.incomplete
2026-09-12 11:17:42 snapshot complete: /tmp/bash-backup-lab/repo/snap-20260912-111742

Unchanged config/app.ini shares an inode between snapshots:

bash
stat -c '%i %n' /tmp/bash-backup-lab/repo/snap-*/config/app.ini
output
20814341 /tmp/bash-backup-lab/repo/snap-20260912-111741/config/app.ini
20814341 /tmp/bash-backup-lab/repo/snap-20260912-111742/config/app.ini

Complete vs incomplete snapshots

The script writes to snap-TIMESTAMP.incomplete, runs rsync, then creates .complete and renames the directory to snap-TIMESTAMP only on success. Incomplete directories never receive .complete.

Trailing slash on SOURCE

With SOURCE=/data/, rsync copies the contents of data into the snapshot. With SOURCE=/data, it nests a data/ directory inside the snapshot. See the rsync command article for full path rules.

text
SOURCE=/data/     →  snap-…/notes.txt
SOURCE=/data      →  snap-…/data/notes.txt

Why --delete is not used here

Every snapshot starts in a new empty directory. Files removed from SOURCE simply do not appear in the new snapshot. --delete matters when synchronizing repeatedly into the same destination directory — not in this layout.

rsync -a preserves permissions, timestamps, symlinks, and recursion for typical Linux trees. ACLs, extended attributes, or hard links inside the source may need extra flags and verification.


Handle Failed and Interrupted Backups

If rsync fails after the incomplete directory is created, the EXIT trap removes only that incomplete path. Complete snapshots are untouched.

Simulate failure after rsync copies data:

bash
cat > /tmp/bash-backup-lab/fail-backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
REPO=/tmp/bash-backup-lab/repo
INCOMPLETE_DIR="${REPO}/snap-failtest.incomplete"
trap '[[ -n ${INCOMPLETE_DIR:-} && -d $INCOMPLETE_DIR ]] && rm -rf -- "$INCOMPLETE_DIR"' EXIT
mkdir -p "$INCOMPLETE_DIR"
rsync -a /tmp/bash-backup-lab/src/ "$INCOMPLETE_DIR/"
false
EOF
chmod +x /tmp/bash-backup-lab/fail-backup.sh

Run the helper and check the exit status:

bash
/tmp/bash-backup-lab/fail-backup.sh
echo exit:$?
output
exit:1

No snap-failtest directory remains:

bash
ls -1d /tmp/bash-backup-lab/repo/snap-failtest* 2>/dev/null || echo 'no failtest snapshot'
output
no failtest snapshot

See Bash trap for EXIT handler scope when you extend cleanup.


Prevent Concurrent Backups with flock

If two backups run at the same time, they can compete for disk I/O and interfere with snapshot creation or retention. A non-blocking flock allows only one backup run at a time — the same pattern as prevent duplicate runs with flock:

bash
mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE"
flock -n 9 || { echo 'another backup is already running'; exit 75; }

Keep the lock file on a local filesystem when possible; flock support can be limited on some NFS and CIFS mounts.

Hold the lock in the background, then start another run:

bash
flock -n /tmp/bash-backup-lab/run/backup.lock sleep 300 &

With the lock held, a second invocation should exit immediately:

bash
DRY_RUN=1 /tmp/bash-backup-lab/backup.sh
output
another backup is already running

The script returns exit code 75 on a lock conflict so a wrapper or monitoring system can distinguish a skipped run from an rsync failure.


Log Backup Runs and rsync Errors

Each run writes logs/backup-YYYYMMDD-HHMMSS.log. Status messages such as rsync start, snapshot complete, and retention actions are written to both the log file and stderr through tee. Because pipefail is enabled, the pipeline fails if rsync fails instead of hiding the failure behind a successful tee:

bash
rsync ... 2>&1 | tee -a "$LOG_FILE"

A permission error or missing path from rsync therefore appears in the same log file as the rsync start line.


Rotate Old Backups

Retention age comes from the .complete marker — created only after a successful rsync — not from the snapshot directory's mtime. find -mtime works with completed 24-hour periods rather than an exact calendar date. This means -mtime +7 does not mean "delete the moment a backup becomes exactly seven days old."

Preview markers older than seven days by backdating .complete:

bash
mkdir -p /tmp/bash-backup-lab/repo/snap-20250801-120000
touch -d '10 days ago' /tmp/bash-backup-lab/repo/snap-20250801-120000/.complete
find /tmp/bash-backup-lab/repo -mindepth 2 -maxdepth 2 -type f -name '.complete' -mtime +7 -print
output
/tmp/bash-backup-lab/repo/snap-20250801-120000/.complete

The script deletes only parent directories named snap-* that contain an expired .complete file. Review the find output before enabling deletion in production.


Schedule the Bash Backup with cron

Point cron at the script's absolute path:

text
0 2 * * * /usr/local/bin/backup.sh

Per-run logs already land under LOG_DIR; you do not need to wrap the script unless you want a separate cron transcript. For systemd timers, overlap rules, and environment pitfalls, see schedule Bash scripts.


Restore Files from an rsync Snapshot

Delete a file from the live tree, then copy it from the latest snapshot that contains .complete:

bash
rm -f /tmp/bash-backup-lab/src/notes.txt
repo=/tmp/bash-backup-lab/repo
shopt -s nullglob
snaps=("$repo"/snap-*)
shopt -u nullglob
if ((${#snaps[@]} == 0)); then
  echo 'No backup snapshots found' >&2
  exit 1
fi
mapfile -t snaps < <(printf '%s\n' "${snaps[@]}" | LC_ALL=C sort -r)
latest=
for d in "${snaps[@]}"; do
  [[ -f $d/.complete ]] || continue
  [[ $d == *.incomplete ]] && continue
  latest=$d
  break
done
if [[ -z $latest ]]; then
  echo 'No complete backup snapshot found' >&2
  exit 1
fi
rsync -a "${latest}/notes.txt" /tmp/bash-backup-lab/restore/
rsync -a "${latest}/config/" /tmp/bash-backup-lab/restore/config-restore/

rsync exits silently when the copy succeeds.


Verify the Restored Backup

Read the restored file and list the tree:

bash
cat /tmp/bash-backup-lab/restore/notes.txt
find /tmp/bash-backup-lab/restore -type f
output
version=2 changed
/tmp/bash-backup-lab/restore/notes.txt
/tmp/bash-backup-lab/restore/config-restore/app.ini

Run this check after path changes and before you rely on cron alone. If the snapshots are stored on the same physical disk as the source, a disk failure can destroy both the original files and the backup. Keep another copy on separate storage for disaster recovery.


rsync Snapshots vs tar Archives

Use rsync snapshots when Use tar when
Files change regularly You need one portable archive
You want multiple restore points The tree is a small static config set
Incremental storage matters Compression into a single file matters
bash
tar -czf /tmp/bash-backup-lab/logs/config-$(date +%F).tar.gz -C /tmp/bash-backup-lab/src config
tar -tzf /tmp/bash-backup-lab/logs/config-$(date +%F).tar.gz
output
config/
config/app.ini

The main script above does not call tar; use it separately when a single archive fits the job.


Bash Backup Script Troubleshooting

Symptom Likely cause Fix
rsync: command not found rsync not installed Install the rsync package for your distribution
Permission denied during rsync Source unreadable to the cron user Fix ownership or ACLs; read the per-run log for the exact path
--link-dest not creating hard links File contents or preserved attributes differ from the previous snapshot rsync requires identity in all preserved attributes; compare stat on both snapshots
Works manually, fails from cron Different user, PATH, or relative paths Use absolute SOURCE/REPO; set PATH in the crontab if needed
another backup is already running Overlapping schedule or stale lock holder Ensure one schedule; check for a stuck process holding LOCK_FILE
No space left on device Repository filesystem full Free space or move REPO; preflight df is advisory only
Snapshot not rotated .complete not old enough for -mtime +N Backdate .complete only in tests; in production wait for age or lower RETENTION_DAYS
Extra data/ directory inside snapshot SOURCE missing trailing / Set SOURCE=/path/to/data/
REPO must not be inside SOURCE Repository under the backup tree Place REPO outside SOURCE

When a Bash Backup Script Is Not Enough

This script is reasonable for backing up files from one host, especially when you want simple snapshots that can be restored with normal Linux tools. For backups across many machines, application-consistent database backups, bare-metal recovery, or managed offsite storage, use a dedicated backup system instead.


Summary

Copy the script, set absolute SOURCE and REPO, dry-run once, then schedule it. Each run adds a timestamped snapshot with optional --link-dest hard links, logs rsync output, and rotates snapshots by .complete age.

Before you trust cron, restore at least one file and one directory from a snapshot to a separate path and read the contents back.


References


Frequently Asked Questions

Is rsync itself a backup system?

rsync is a file copy tool. It becomes a backup workflow when you add snapshots, retention, locking, logging, and tested restore steps around it.

It points rsync at a previous snapshot directory. Unchanged files can be hard-linked into the new snapshot, saving space while each snapshot remains a normal directory tree.

rsync hard-links only when the candidate file is identical in all preserved attributes to the file in the --link-dest directory. Any difference in permissions, ownership, timestamps, or other preserved metadata forces a new copy instead of a link.

Can I use this Bash backup script with a remote server?

Not with REPO=user@host:/path as written. Retention, locking, and renaming completed snapshots need a local filesystem. Mount remote storage locally or push finished snapshots with a separate rsync over SSH step.

How do I prove the backup can be restored?

Copy at least one file and one directory from a complete snapshot to a separate restore path. Read the file contents and list the restored tree before you rely on the schedule.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)