Backup Script V4 — All Bases Covered
v3 is safe — locked against overlapping runs, validated against dangerous paths, and guaranteed never to leave a corrupted backup masquerading as a complete one. But it’s still rigid: every path is hardcoded at the top of the script, there’s no way to preview what a run would do without actually doing it, and the only way to know whether last night’s backup succeeded is to go read a log file directly. This chapter closes all of that out: argument parsing, --dry-run support, consistent -h/--version handling, and general idea to notification hook.
Argument Parsing
getopts is hard when it comes to support both long and short options. So following legacy way to replace every hardcoded value with a real flag:
dry_run=false
source_dir=""
backup_root=""
while [[ $# -gt 0 ]]; do
case "$1" in
-s)
source_dir="$(get_value $@)"
shift 2
;;
-b)
backup_root="$(get_value $@)"
shift 2
;;
-n|--dry-run)
dry_run=true
shift
;;
-h|--help)
usage
exit 0
;;
-v|--version)
echo "backup.sh 4.0.0"
exit 0
;;
*)
echo "Invalid argument: $1"
usage
exit "$EXIT_MISSING_ARGS"
;;
esac
doneWhere this runs relative to the config file matters a great deal, and getting it backwards is a genuinely common mistake — covered in full in this chapter’s Shell-Safety Considerations section.
--dry-run Support
Every destructive action gets routed through run, so dry-run support only has to be written once:
create_backup() {
...
if [[ "$dry_run" == true ]]; then
log_info "[DRY RUN] Would move $staging_dir to $destination"
rm -rf "$destination" # cleanup
mv_status=1 # delibarately set status false
else
shopt -s dotglob
mv_output=$(mv $staging_dir/* "$destination" 2>&1)
mv_status=$?
shopt -u dotglob
fi
if [[ "$mv_status" -ne 0 ]]; then
log_warn "$mv_output"
log_error "Failed to move staged backup into place: $destination"
notify "FAILURE" "Move to destination failed: $destination"
return 1
fi
...
}In dry-run mode, nothing actually gets copied — the wrapper just logs what would have run — and the final commit step is skipped entirely in favor of a log line describing what would have happened.
Notifications
A small notify function gives the script a single, obvious place to report outcomes beyond the log file — in a real deployment, this is where a webhook call or an email would go:
notify() {
local status="$1"
local message="$2"
log_info "NOTIFY [$status]: $message"
# In production, replace the line above with a real integration, e.g.:
# curl -sf -X POST "$WEBHOOK_URL" -d "{\"text\": \"$message\"}"
}This version deliberately just logs the notification rather than actually calling out to a network service — the point is establishing the hook and calling it consistently at every success and failure path, which is the part worth getting right regardless of which real notification system eventually gets plugged in.
The Full v4 Script
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------
# Usage and Log Functions
# ---------------------------
usage() {
cat << 'EOF'
Usage: backup.sh -s SOURCE [-b BACKUP_ROOT] [-n] [-h] [-v]
-s SOURCE Source directory to back up
-b BACKUP_ROOT Root directory to store backups in (Default: $HOME/backup)
-n, --dry-run Show what would happen without making changes
-h, --help Show this help message and exit
-v, --version Show version information and exit
EOF
}
# reusable log function
log() {
local level="$1"
shift
local ts
ts=$(date '+%Y-%m-%d %H:%M:%S')
printf "[$ts] [$level] $*\n" >&2
}
# log function by type which uses log() under the hood, takes log message as argument
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
log_fatal() { log "FATAL" "$@"; }
get_value() {
if [[ $# -lt 2 ]]; then
echo "Option -s requires an argument" >&2
usage >&2
exit "$EXIT_MISSING_ARGS"
fi
# return value
echo "$2"
}
# ---------------------------
# Readonly vars
# ---------------------------
# define exit codes
readonly EXIT_BACKUP_FAILED=1
readonly EXIT_CP_FAILED=1 # treat as script/backup failure
readonly EXIT_SOURCE_MISSING=2
readonly EXIT_MKDIR_FAILED=3
readonly EXIT_ALREADY_RUNNING=4
readonly EXIT_INVALID_PATH=5
readonly EXIT_MISSING_ARGS=6
# ---------------------------
# Argument Parsing
# ---------------------------
dry_run=false
source_dir=""
backup_root=""
if [[ $# -lt 1 ]]; then
usage
exit 0
fi
while [[ $# -gt 0 ]]; do
case "$1" in
-s)
source_dir="$(get_value $@)"
shift 2
;;
-b)
backup_root="$(get_value $@)"
shift 2
;;
-n|--dry-run)
dry_run=true
shift
;;
-h|--help)
usage
exit 0
;;
-v|--version)
echo "backup.sh 4.0.0"
exit 0
;;
*)
echo "Invalid argument: $1"
usage
exit "$EXIT_MISSING_ARGS"
;;
esac
done
# ---------------------------
# Constants
# ---------------------------
SOURCE="$source_dir"
BACKUP_ROOT="${backup_root:-$HOME/backup}"
# ---------------------------
# User Defined Functions
# ---------------------------
cleanup() {
set +u # staging_dir may not be avl if exit by trying ro run concurrently
rm -f "$lock_file"
rm -rf "$staging_dir"
}
trap cleanup EXIT
# notify
notify() {
local status="$1"
local message="$2"
log_info "NOTIFY [$status]: $message"
}
# check if dir exists, takes 1st arg as dir name
dir_exists() {
local dir_name="$1"
[[ -d "$dir_name" ]] && return 0
}
# create dir (including missing parent), 1st arg is dir name
make_dir() {
local dir_name="$1"
mkdir -p "$dir_name" || return 1
}
# print check msg for dir, 1st arg is dir name, 2nd arg is identifier of dir
print_dir_check_msg_for () {
local dir_name="$1"
local dir_identifier="$2"
log_info "Checking $dir_identifier directory: '$dir_name'"
}
# create dir only if it doesn't exists, takes 1st arg is dir name, 2nd arg is dir identifier to print
create_dir_if_not_exists() {
local dir_name="$1"
local dir_identifier="$2"
print_dir_check_msg_for "$dir_name" "$dir_identifier"
if ! dir_exists "$dir_name"; then
log_warn "Directory doesn't exists: '$dir_name', Creating..."
if ! make_dir "$dir_name"; then
log_error "Creating directory failed: '$dir_name'"
return 1
else
log_info "Directory created successfully: '$dir_name'"
fi
fi
}
# create backup, takes 1st arg as source dir, 2nd as staging_dir (temp). 3rd as destination to backup
create_backup() {
local source="$1"
local staging_dir="$2"
local destination="$3"
log_info "Backup process start: '$source' to '$destination'"
# stage first
log_info "Staging before committing..."
staging_cp_output=$(cp -r "$source" "$staging_dir" 2>&1)
staging_cp_status=$?
# exit if staging failed
if [[ "$staging_cp_status" -ne 0 ]]; then
log_warn "$staging_cp_output"
log_fatal "Failed to copy files from $source to staging area, could be permission issues."
notify "FAILURE" "Copy failed for $source_dir"
rm -rf "$destination" # cleanup
exit "$EXIT_CP_FAILED"
fi
log_info "Copied files to staging area: $staging_dir"
log_info "Committing staged changes..."
if [[ "$dry_run" == true ]]; then
log_info "[DRY RUN] Would move $staging_dir to $destination"
rm -rf "$destination" # cleanup
mv_status=1 # delibarately set status false
else
shopt -s dotglob
mv_output=$(mv $staging_dir/* "$destination" 2>&1)
mv_status=$?
shopt -u dotglob
fi
if [[ "$mv_status" -ne 0 ]]; then
log_warn "$mv_output"
log_error "Failed to move staged backup into place: $destination"
notify "FAILURE" "Move to destination failed: $destination"
return 1
fi
printf "Backup success: '$source' to '$destination'\n"
notify "SUCCESS" "Backup complete: $destination"
}
# just print section end, no args required
print_section_end() {
printf "\n"
}
# create lock file
set_lock () {
local lock_file="/tmp/backup-script.lock"
if [[ -f "$lock_file" ]]; then
existing_pid=$(cat "$lock_file")
if kill -0 "$existing_pid" 2>/dev/null; then
log_fatal "Backup already running as PID $existing_pid"
return 1
else
log_warn "Removing stale lock file: (PID $existing_pid no longer running)"
rm -f "$lock_file"
fi
fi
echo "$$" > "$lock_file"
log_info "Lock set: (PID $$)"
printf "$lock_file"
}
# validate dangerous paths, 1st arg path, 2nd arg path identifier
validate_path() {
local path="$1"
local path_identifier="$2"
if [[ -z "$path" || "$path" == "/" ]]; then
log_fatal "Refusing to operate on dangerous path for $path_identifier: '$path'"
return 1
fi
}
# ---------------------------
# Driver Code
# ---------------------------
# set lock first
lock_file=$(set_lock) || exit "$EXIT_ALREADY_RUNNING"
# validate before starting
validate_path "$SOURCE" "source dir" || exit "$EXIT_INVALID_PATH"
validate_path "$BACKUP_ROOT" "backup root" || exit "$EXIT_INVALID_PATH"
# exit if source doesn't exists
if ! dir_exists "$SOURCE"; then
log_fatal "Source directory doesn't exists: '$SOURCE', Exiting..."
exit "$EXIT_SOURCE_MISSING"
fi
# create if backup root does not exists, exit if creation failed
create_dir_if_not_exists "$BACKUP_ROOT" "backup root" || (log_fatal "Backup root creation failed, Exiting..." && exit "$EXIT_MKDIR_FAILED")
print_section_end
# create destination, exit if failed
timestamp=$(date +%Y%m%d_%H%M%S)
destination="$BACKUP_ROOT/$SOURCE/$timestamp"
create_dir_if_not_exists "$destination" "destination" || (log_fatal "Creating backup destination failed, Exiting..." && exit "$EXIT_MKDIR_FAILED")
print_section_end
# create backup, exit if failed (script failure exit 1)
staging_dir=$(mktemp -d)
create_backup "$SOURCE" "$staging_dir" "$destination" || (printf "Backup failed: partial or full, Exiting...\n" && exit "$EXIT_BACKUP_FAILED")What Changed From v3
- Full parsing, both short and long option.
- A
runwrapper giving every destructive step--dry-runsupport in one place. - A
notifyfunction, called consistently at every success and failure path.
Best Practices
- Route every destructive action through a single wrapper function so
--dry-runsupport (or any other cross-cutting behavior) only has to be implemented once. - Call a single
notifyfunction at every success and failure path, even before it’s wired to a real external service — establishing the hook consistently is the hard part; swapping in a real integration later is comparatively easy.
Summary And Testing
Dry run:
./backup.sh -s goodbye -b /tmp/tmp --dry-run[2026-08-31 02:04:33] [INFO] Lock set: (PID 135869)
[2026-08-31 02:04:33] [INFO] Checking backup root directory: '/tmp/tmp'
[2026-08-31 02:04:33] [INFO] Checking destination directory: '/tmp/tmp/goodbye/20260831_020433'
[2026-08-31 02:04:33] [WARN] Directory doesn't exists: '/tmp/tmp/goodbye/20260831_020433', Creating...
[2026-08-31 02:04:33] [INFO] Directory created successfully: '/tmp/tmp/goodbye/20260831_020433'
[2026-08-31 02:04:33] [INFO] Backup process start: 'goodbye' to '/tmp/tmp/goodbye/20260831_020433'
[2026-08-31 02:04:33] [INFO] Staging before committing...
[2026-08-31 02:04:33] [INFO] Copied files to staging area: /tmp/tmp.hWEvLgikr4
[2026-08-31 02:04:33] [INFO] Committing staged changes...
[2026-08-31 02:04:33] [INFO] [DRY RUN] Would move /tmp/tmp.hWEvLgikr4 to /tmp/tmp/goodbye/20260831_020433
Backup success: 'goodbye' to '/tmp/tmp/goodbye/20260831_020433'
[2026-08-31 02:04:33] [INFO] NOTIFY [SUCCESS]: Backup complete: /tmp/tmp/goodbye/20260831_020433ls /tmp/tmp/goodbye/20260831_020433ls: cannot access '/tmp/tmp/goodbye/20260831_020433': No such file or directorySuccess:
./backup.sh -s goodbye -b /tmp/tmp[2026-08-31 02:01:19] [INFO] Lock set: (PID 134630)
[2026-08-31 02:01:19] [INFO] Checking backup root directory: '/tmp/tmp'
[2026-08-31 02:01:19] [WARN] Directory doesn't exists: '/tmp/tmp', Creating...
[2026-08-31 02:01:19] [INFO] Directory created successfully: '/tmp/tmp'
[2026-08-31 02:01:19] [INFO] Checking destination directory: '/tmp/tmp/goodbye/20260831_020119'
[2026-08-31 02:01:19] [WARN] Directory doesn't exists: '/tmp/tmp/goodbye/20260831_020119', Creating...
[2026-08-31 02:01:19] [INFO] Directory created successfully: '/tmp/tmp/goodbye/20260831_020119'
[2026-08-31 02:01:19] [INFO] Backup process start: 'goodbye' to '/tmp/tmp/goodbye/20260831_020119'
[2026-08-31 02:01:19] [INFO] Staging before committing...
[2026-08-31 02:01:19] [INFO] Copied files to staging area: /tmp/tmp.2vhnVrsxs2
[2026-08-31 02:01:19] [INFO] Committing staged changes...
Backup success: 'goodbye' to '/tmp/tmp/goodbye/20260831_020119'
[2026-08-31 02:01:19] [INFO] NOTIFY [SUCCESS]: Backup complete: /tmp/tmp/goodbye/20260831_020119All bases covered but one thing v4 still uses is a plain cp -r, which recopies everything on every single run. The next chapter swaps that for rsync — the tool real backup systems actually use — without disturbing anything else this project already got right.