Skip to content

Backup Script V3 — Adding Safety


v2 fixed reporting — it no longer lies about success. But it still has a subtler problem: if cp fails partway through copying (one unreadable file among many, say), v2 correctly logs the error and exits nonzero — and still leaves behind a directory, sitting in backup_root under a perfectly normal-looking timestamped name, containing an incomplete, silently corrupted backup. Anyone who later just lists backup_root and grabs the most recent entry — without separately checking that day’s logs — has no way to tell it apart from a genuinely complete one. This chapter closes that gap, along with three others: concurrent runs, dangerous input paths, and guaranteed cleanup.

Evolving v2: What’s Still Missing

  • No protection against two copies of this script running at once — a slow backup overlapping with a second scheduled run could corrupt both.
  • No validation of the paths involved — nothing stops source_dir or backup_root from ending up empty or set to /, which would be catastrophic the moment this script (or a future modified version of it) performs any destructive operation on those paths.
  • No atomicity — as just described, a partial failure can leave a corrupted directory indistinguishable, by name alone, from a real backup.
  • No guaranteed cleanup — nothing removes stray resources if the script is interrupted partway through.

Locking — Preventing Concurrent Runs

Straight from the Real-World Patterns chapter’s lockfile pattern:

# 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"

# set lock first
set_lock || exit "$EXIT_ALREADY_RUNNING"
}

Input Validation — Refusing Dangerous Paths

This script only ever calls cp, never anything as destructive as rm -rf, but an empty or /-valued variable landing next to a destructive command is exactly the kind of risk worth guarding against early, as a habit — especially since scripts evolve, and a future version of this one might well add a step that deletes old backups:

# 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
}

# validate before starting
validate_path "$SOURCE" "source dir" || exit "$EXIT_INVALID_PATH"
validate_path "$BACKUP_ROOT" "backup root" || exit "$EXIT_INVALID_PATH"

Safe Temporary Resources: Staging Before Committing

This is the fix for the atomicity problem described at the start of this chapter. Instead of copying directly into the final, real-looking destination name, copy into an anonymous mktemp -d staging directory first, and only move it into its real, permanent name once the copy has fully succeeded:

# 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."
        rm -rf "$destination" # cleanup
        exit "$EXIT_CP_FAILED"
    fi 

    log_info "Copied files to staging area: $staging_dir"

    log_info "Committing staged changes..."
    mv_output=$(mv "$staging_dir" "$destination")
    mv_status=$?
    if [[ "$mv_status" -ne 0 ]]; then
        log_warn "$mv_output"
        log_error "Failed to move staged backup into place: $destination"
        return 1
    fi
    printf "Backup success: '$source' to '$destination'\n"
}

The move is what actually matters here: $destination — the name every future run and every human browsing BACKUP_ROOT will see — only ever comes into existence at the very last step, once the copy is already known to have fully succeeded. If cp fails partway through, the incomplete data sits in the staging directory instead, under a name nobody’s looking for, and never gets renamed into the real backup location at all. The destination name is now a reliable signal: if it exists, the backup behind it is complete, full stop.

destination cleanup can’t be left for TRAP. It should be cleaned only on failure as such: rm -rf "$destination" becore exit "$EXIT_CP_FAILED".

Cleanup With trap

A single EXIT trap, registered once staging begins, covers both the staging directory and the lockfile — centralizing cleanup, rather than duplicating it across multiple exit paths:

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

rm -rf "$staging_dir" here is harmless even after a successful mv — by then, nothing exists at that path anymore, and rm -rf on a nonexistent path simply does nothing.

The Full v3 Script

#!/usr/bin/env bash

set -euo pipefail

usage() {
    printf '%s\n' \
    "Usage: $0 <source>" \
    "<source> source directory to backup"
}

if [[ $# -ne 1 ]]; then
    usage
    exit 1
fi

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

# ---------------------------
# Constants & Readonly Vars
# ---------------------------
SOURCE="$1"
BACKUP_ROOT="$HOME/backup"

# 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

# ---------------------------
# User Defined Functions
# ---------------------------

# 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" "$@"; }

# 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."
        rm -rf "$destination" # cleanup
        exit "$EXIT_CP_FAILED"
    fi 

    log_info "Copied files to staging area: $staging_dir"

    log_info "Committing staged changes..."
    mv_output=$(mv "$staging_dir" "$destination")
    mv_status=$?
    if [[ "$mv_status" -ne 0 ]]; then
        log_warn "$mv_output"
        log_error "Failed to move staged backup into place: $destination"
        return 1
    fi
    printf "Backup success: '$source' to '$destination'\n"
}

# 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 v2

  • A lockfile with liveness checking, preventing overlapping runs and self-healing from a stale lock left by an uncleanly killed prior run.
  • Path validation, refusing to proceed if source_dir or backup_root are empty or /.
  • Staging via mktemp -d, committed only via mv, guaranteeing $destination only ever exists once a backup is genuinely complete.
  • A single consolidated EXIT trap cleaning up both the staging directory and the lockfile, however the script ends.
  • create_backup actually takes three args — because cleanup needs to access those variables.

Best Practices

  • Never copy or write directly into a destination name that implies completeness. Stage in an anonymous temporary location, and commit with a single move once you know the work actually succeeded.
  • Validate any path a script will operate on, early, even for operations that aren’t currently destructive — scripts change over time, and today’s read-only script is often tomorrow’s script that also deletes old backups.
  • Use a single EXIT trap for all cleanup, registered as soon as the resources needing cleanup actually exist, rather than scattering rm calls across every possible exit path individually.

Summary And Testing

Lock works:

./backup.sh goodbye/
[2026-08-30 23:27:52] [FATAL] Backup already running as PID 86353

Cleanup works:

ls /tmp/backup-script.lock
ls: cannot access '/tmp/backup-script.lock': No such file or directory

Happy Ending for v3:

./backup.sh database_project/
[2026-08-31 00:07:02] [INFO] Lock set: (PID 100909)
[2026-08-31 00:07:02] [INFO] Checking backup root directory: '/home/sonu-nigam/backup'

[2026-08-31 00:07:02] [INFO] Checking destination directory: '/home/sonu-nigam/backup/database_project//20260831_000702'
[2026-08-31 00:07:02] [WARN] Directory doesn't exists: '/home/sonu-nigam/backup/database_project//20260831_000702', Creating...
[2026-08-31 00:07:02] [INFO] Directory created successfully: '/home/sonu-nigam/backup/database_project//20260831_000702'

[2026-08-31 00:07:02] [INFO] Backup process start: 'database_project/' to '/home/sonu-nigam/backup/database_project//20260831_000702'
[2026-08-31 00:07:02] [INFO] Staging before committing...
[2026-08-31 00:07:02] [INFO] Copied files to staging area: /tmp/tmp.ZI7IM8sgKz
[2026-08-31 00:07:02] [INFO] Committing staged changes...
Backup success: 'database_project/' to '/home/sonu-nigam/backup/database_project//20260831_000702'

We will go more in depth in the next version. Aren’t you ready yet?

Last updated on