Backup Script V2 — Error Handling and Logging
Come here only after completing previous chapter. This is an evolution to our backup.sh script by adding logging, error handling and more.
Evolving v1: Adding A Logging Function
Reusable logic:
log() {
local level="$1"
shift
local ts
ts=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$ts] [$level] $*" >&2
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }Every message this script produces from here on goes through one of these, giving every line a consistent, parseable shape — genuinely useful the first time this script’s output ends up in a log file being read weeks later, rather than watched live on a terminal.
Meaningful Exit Codes
Each failure gets its own distinct, documented exit code, rather than a single undifferentiated exit 1:
# define exit codes
radonly EXIT_BACKUP_FAILED=1
radonly EXIT_CP_FAILED=1 # treat as script/backup failure
radonly EXIT_SOURCE_MISSING=2
radonly EXIT_MKDIR_FAILED=3Anything invoking this script — a cron job’s alerting, a monitoring dashboard, a person checking $? after a manual run — can now tell these three failure modes apart without having to parse the log message text at all.
Explicit Exit Status For Each Step
Rather than relying on set -e to silently halt the script, each step that can fail gets its own explicit check, paired with a specific log message:
# exit if source doesn't exists
if ! dir_exists "$SOURCE"; then
printf "Source directory doesn't exists: '$SOURCE'\n"
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")
# create backup, exit if failed (script failure exit 1)
create_backup "$SOURCE" "$destination" || (printf "Backup failed: partial or full, Exiting...\n" && exit "$EXIT_BACKUP_FAILED")Command tested directly by if doesn’t trigger set -e on its own — which is exactly what’s being used deliberately here: wrapping each risky step in if ! command; then ...; fi intentionally opts out of set -e’s abrupt, unexplained stop, in favor of a specific, logged, intentional one. This is a genuine, deliberate application of one of set -e’s documented exceptions, not an oversight. Even if command failure triggers set -e, it’s good to be explicit and || exit <code> is a way to be explicit.
Explicitly Logging To stderr
Instead of normal printf which outputs everything to stdout, we explictly log according to type. For example:
# 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
}Genuine Output To stdout
Only success of failure output should be redirected to stdout. So, bare printf is left for:
# create backup, takes source and dest
create_backup() {
...
printf "Backup success: '$source' to '$destination'\n"
}
# create backup, exit if failed (script failure exit 1)
create_backup "$SOURCE" "$destination" || (printf "Backup failed: partial or full, Exiting...\n" && exit "$EXIT_BACKUP_FAILED")Section Delimiter Removed/Replaced
In version1:
SECTION_END="************************************************************************************************"
# just print section end, no args required
print_section_end() {
printf "\n$SECTION_END\n$SECTION_END\n\n"
}With is replaced as:
# just print section end, no args required
print_section_end() {
printf "\n"
}Note
Since printf here is not redirected, this is treated as stdout. You can delete these extra newline if you don’t want. Alternatively, in scripts or anywhere, you can:
./backup.sh my_project | tr -d '\n'The Full v2 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
# ---------------------------
# 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
# ---------------------------
# 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 source and dest
create_backup() {
local source="$1"
local destination="$2"
log_info "Backup process start: '$source' to '$destination'"
cp_output=$(cp -r "$source" "$destination" 2>&1)
cp_status=$?
if [[ "$cp_status" -ne 0 ]]; then
log_warn "$cp_output"
log_fatal "Either all or some files failed to backup, could be permission issues."
return 1
fi
printf "Backup success: '$source' to '$destination'\n"
}
# just print section end, no args required
print_section_end() {
printf "\n"
}
# ---------------------------
# Driver Code
# ---------------------------
# 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)
create_backup "$SOURCE" "$destination" || (printf "Backup failed: partial or full, Exiting...\n" && exit "$EXIT_BACKUP_FAILED")What Changed From v1
- Distinct exit codes per failure type, replacing the complete absence of exit-code handling in v1.
- A logging function, replacing v1’s single bare
printfwith log types.
Summary And Testing
We have now nice output — structured with logs:
./backup.sh database_project[2026-08-30 22:54:05] [INFO] Checking backup root directory: '/home/alice/backup'
[2026-08-30 22:54:05] [WARN] Directory doesn't exists: '/home/alice/backup', Creating...
[2026-08-30 22:54:05] [INFO] Directory created successfully: '/home/alice/backup'
[2026-08-30 22:54:05] [INFO] Checking destination directory: '/home/alice/backup/database_project/20260830_225405'
[2026-08-30 22:54:05] [WARN] Directory doesn't exists: '/home/alice/backup/database_project/20260830_225405', Creating...
[2026-08-30 22:54:05] [INFO] Directory created successfully: '/home/alice/backup/database_project/20260830_225405'
[2026-08-30 22:54:05] [INFO] Backup process start: 'database_project' to '/home/alice/backup/database_project/20260830_225405'
Backup success: 'database_project' to '/home/alice/backup/database_project/20260830_225405'Only this line lands in stdout:
Backup success: 'database_project' to '/home/alice/backup/database_project/20260830_225405'To check the behavior, you can redirect stderr to /dev/null and remove those extra newlines:
./backup.sh database_project 2>/dev/null | tr -d '\n'Backup success: 'database_project' to '/home/alice/backup/database_project/20260830_225405'And if something fails:
./backup.sh root_owned_project 2>/dev/null | tr -d '\n'Backup failed: partial or full, Exiting...Full logs on failure:
[2026-08-30 23:06:59] [INFO] Checking backup root directory: '/home/alice/backup'
[2026-08-30 23:06:59] [INFO] Checking destination directory: '/home/alice/backup/root_owned_project//20260830_230659'
[2026-08-30 23:06:59] [WARN] Directory doesn't exists: '/home/alice/backup/root_owned_project//20260830_230659', Creating...
[2026-08-30 23:06:59] [INFO] Directory created successfully: '/home/alice/backup/root_owned_project//20260830_230659'
[2026-08-30 23:06:59] [INFO] Backup process start: 'root_owned_project/' to '/home/alice/backup/root_owned_project//20260830_230659'
[2026-08-30 23:06:59] [WARN] cp: cannot access 'root_owned_project/': Permission denied
[2026-08-30 23:06:59] [FATAL] Either all or some files failed to backup, could be permission issues.
Backup failed: partial or full, Exiting...We will go more in depth in the next version. Are you ready yet?