| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10rsync 3.4.4-1.el10_2coreutils 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 |
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):
#!/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:
command -v bash rsync flock realpath find/usr/bin/bash
/usr/bin/rsync
/usr/bin/flock
/usr/bin/realpath
/usr/bin/findThis 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:
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.shA nested repository is rejected before rsync starts:
SOURCE=/tmp/bash-backup-lab/src/ REPO=/tmp/bash-backup-lab/src/backups /tmp/bash-backup-lab/backup.sh2026-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:
DRY_RUN=1 /tmp/bash-backup-lab/backup.sh2026-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.incompleteIn 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:
/tmp/bash-backup-lab/backup.sh2026-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-111741List the new snapshot — a .complete marker and mirrored files should be present:
find /tmp/bash-backup-lab/repo/snap-20260912-111741 -type f | sort/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.txtHow 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:
echo 'version=2 changed' > /tmp/bash-backup-lab/src/notes.txt
/tmp/bash-backup-lab/backup.sh2026-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-111742Unchanged config/app.ini shares an inode between snapshots:
stat -c '%i %n' /tmp/bash-backup-lab/repo/snap-*/config/app.ini20814341 /tmp/bash-backup-lab/repo/snap-20260912-111741/config/app.ini
20814341 /tmp/bash-backup-lab/repo/snap-20260912-111742/config/app.iniComplete 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.
SOURCE=/data/ → snap-…/notes.txt
SOURCE=/data → snap-…/data/notes.txtWhy --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:
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.shRun the helper and check the exit status:
/tmp/bash-backup-lab/fail-backup.sh
echo exit:$?exit:1No snap-failtest directory remains:
ls -1d /tmp/bash-backup-lab/repo/snap-failtest* 2>/dev/null || echo 'no failtest snapshot'no failtest snapshotSee 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:
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:
flock -n /tmp/bash-backup-lab/run/backup.lock sleep 300 &With the lock held, a second invocation should exit immediately:
DRY_RUN=1 /tmp/bash-backup-lab/backup.shanother backup is already runningThe 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:
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:
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/tmp/bash-backup-lab/repo/snap-20250801-120000/.completeThe 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:
0 2 * * * /usr/local/bin/backup.shPer-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:
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:
cat /tmp/bash-backup-lab/restore/notes.txt
find /tmp/bash-backup-lab/restore -type fversion=2 changed
/tmp/bash-backup-lab/restore/notes.txt
/tmp/bash-backup-lab/restore/config-restore/app.iniRun 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 |
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.gzconfig/
config/app.iniThe 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
- rsync manual —
-a,--link-dest,--dry-run, trailing-slash behavior - flock(1) — non-blocking locks and file descriptors
- GNU findutils: Time/Age Ranges — how
-mtime +Nrounds ages - Bash Reference Manual —
set -o pipefail,trap, arrays - Red Hat: Scheduling jobs with cron — recurring job basics
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.
What does --link-dest do?
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.
Why is --link-dest not creating hard links?
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.

