Skip to content

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
done

Where 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 run wrapper giving every destructive step --dry-run support in one place.
  • A notify function, called consistently at every success and failure path.

Best Practices

  • Route every destructive action through a single wrapper function so --dry-run support (or any other cross-cutting behavior) only has to be implemented once.
  • Call a single notify function 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_020433
ls /tmp/tmp/goodbye/20260831_020433
ls: cannot access '/tmp/tmp/goodbye/20260831_020433': No such file or directory

Success:

./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_020119

All 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.

Last updated on