Most Bash scripts fail silently. A command fails, but the script keeps running, corrupting data or creating inconsistent states. Professional scripts handle errors gracefully: they stop on failures, clean up temporary files, and tell you what went wrong. In this lesson, you'll learn the error handling and debugging techniques that separate fragile scripts from production-ready automation.

1. Learning Objectives

By the end of this lesson, you will be able to:

  • Use set -e to exit on errors
  • Prevent undefined variable bugs with set -u
  • Debug scripts with set -x and PS4
  • Implement trap for cleanup operations
  • Use shellcheck to find bugs automatically
  • Create proper logging for scripts

2. Why This Matters

Real-world scenario: Your deployment script runs 50 commands. Command #23 fails, but the script continues. It tries to deploy with incomplete files, corrupts the server, and you spend 2 hours recovering. With proper error handling, the script would have stopped immediately, logged the error, and alerted you.

3. Core Concepts

The Set Command (Safety Options)

#!/bin/bash
# Standard safety options for production scripts
set -e          # Exit immediately if any command fails
set -u          # Treat unset variables as errors
set -o pipefail # Pipeline fails if any command in pipe fails

# Or combine them:
set -euo pipefail

# Demonstration
set -e  # Comment this out to see the difference

echo "This command will succeed"
ls /tmp

echo "This command will fail"
ls /nonexistent_directory

echo "This line will NOT run if set -e is active"
Essential safety options
kubectl get pods -w
$ ./script_with_set_e.sh
This command will succeed
This command will fail
ls: cannot access '/nonexistent_directory': No such file or directory
# Script stops here - last line never runs

$ ./script_without_set_e.sh
This command will succeed
This command will fail
ls: cannot access '/nonexistent_directory': No such file or directory
This line WILL run - this is dangerous!
set -e prevents continuing after errors
#!/bin/bash
# set -u prevents undefined variables
set -u

name="Alex"
echo "Name: $name"
echo "Age: $age"  # This will cause an error - age not defined!

# To allow undefined variables temporarily:
set +u  # Disable
maybe_undefined_var="${var:-default}"  # Safer way with default
set -u  # Re-enable
set -u catches undefined variables
#!/bin/bash
# set -o pipefail - pipeline fails if any command fails
set -o pipefail

# Without pipefail: only the last command's exit code matters
# With pipefail: if any command fails, the whole pipeline fails

echo "Testing pipefail"

# This will fail (grep fails)
ls /tmp | grep nonexistent | wc -l
echo "Exit code: $?"

# Without pipefail, this would succeed because wc -l succeeds!
# With pipefail, it fails because grep failed
pipefail catches failures in the middle of pipelines

Debugging with set -x

#!/bin/bash
# set -x - print each command before executing (trace mode)
set -x

name="Alex"
echo "Hello, $name"
ls /tmp

# Turn off tracing temporarily
set +x
echo "This won't be traced"
set -x

# Customize trace output (PS4)
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x

echo "With line numbers in trace"

# Run script with tracing without modifying file:
# bash -x script.sh
Debugging with trace mode
kubectl get pods -w
$ bash -x script.sh
+ name=Alex
+ echo 'Hello, Alex'
Hello, Alex
+ ls /tmp
file1.txt file2.txt
+ echo 'This won\'t be traced'
This won't be traced
+ PS4='+ ${BASH_SOURCE}:${LINENO}: '
+ echo 'With line numbers in trace'
+ script.sh:10: echo 'With line numbers in trace'
With line numbers in trace
Trace output with line numbers

Trap: Clean Up on Exit

#!/bin/bash
# trap - execute commands on signals or script exit

# Create a temporary file
TEMP_FILE=$(mktemp)

# Cleanup function
cleanup() {
    echo "Cleaning up..."
    rm -f "$TEMP_FILE"
    echo "Temporary file removed"
}

# Trap EXIT (script ends, success or failure)
trap cleanup EXIT

# Trap specific signals
trap 'echo "Script interrupted"; exit 1' INT TERM

# Main script
echo "Creating temp file: $TEMP_FILE"
date > "$TEMP_FILE"
echo "Contents of temp file:"
cat "$TEMP_FILE"

# Simulate work
echo "Working..."
sleep 2

# Cleanup runs automatically when script exits
Using trap for cleanup
#!/bin/bash
# Advanced trap usage for lock files

LOCK_FILE="/tmp/myscript.lock"

# Check if script is already running
if [[ -f "$LOCK_FILE" ]]; then
    echo "Script already running (PID: $(cat $LOCK_FILE))"
    exit 1
fi

# Cleanup function
cleanup() {
    echo "Cleaning up lock file..."
    rm -f "$LOCK_FILE"
}

# Set traps
trap cleanup EXIT INT TERM

# Create lock file with our PID
echo $$ > "$LOCK_FILE"
echo "Lock file created with PID: $$"

# Script work here
echo "Running script..."
sleep 10
echo "Script finished"

# Lock file will be removed automatically

# Multiple traps in one
trap 'echo "Error on line $LINENO"; cleanup; exit 1' ERR
Lock file pattern with trap

ShellCheck: Automatic Bug Finder

# Install shellcheck
# Ubuntu/Debian
sudo apt install shellcheck

# macOS
brew install shellcheck

# Run shellcheck
shellcheck myscript.sh

# Common issues shellcheck catches:

# 1. Unquoted variables
# Bad:
rm $file
# Good:
rm "$file"

# 2. Using ls in loops
# Bad:
for file in $(ls *.txt)
# Good:
for file in *.txt

# 3. Comparing numbers with string operators
# Bad:
if [ "$a" == "$b" ]  # Works but not for numbers
# Good for numbers:
if [ "$a" -eq "$b" ]

# 4. Missing double quotes
# Bad:
if [ $var = value ]
# Good:
if [ "$var" = "value" ]
Shellcheck installation and common fixes

Proper Logging

#!/bin/bash
# logging.sh - Professional logging functions

# Log levels
LOG_LEVEL_ERROR=1
LOG_LEVEL_WARNING=2
LOG_LEVEL_INFO=3
LOG_LEVEL_DEBUG=4

# Current log level (set via environment or config)
CURRENT_LOG_LEVEL=${LOG_LEVEL:-3}
LOG_FILE="/var/log/myscript.log"

# Color codes for console
COLOR_RED='\033[0;31m'
COLOR_YELLOW='\033[1;33m'
COLOR_GREEN='\033[0;32m'
COLOR_BLUE='\033[0;34m'
COLOR_NC='\033[0m'

# Logging functions
log_error() {
    local msg="$1"
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    
    if [[ $LOG_LEVEL_ERROR -le $CURRENT_LOG_LEVEL ]]; then
        echo -e "${COLOR_RED}[ERROR]${COLOR_NC} $timestamp - $msg"
        echo "[ERROR] $timestamp - $msg" >> "$LOG_FILE"
    fi
}

log_warning() {
    local msg="$1"
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    
    if [[ $LOG_LEVEL_WARNING -le $CURRENT_LOG_LEVEL ]]; then
        echo -e "${COLOR_YELLOW}[WARNING]${COLOR_NC} $timestamp - $msg"
        echo "[WARNING] $timestamp - $msg" >> "$LOG_FILE"
    fi
}

log_info() {
    local msg="$1"
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    
    if [[ $LOG_LEVEL_INFO -le $CURRENT_LOG_LEVEL ]]; then
        echo -e "${COLOR_GREEN}[INFO]${COLOR_NC} $timestamp - $msg"
        echo "[INFO] $timestamp - $msg" >> "$LOG_FILE"
    fi
}

log_debug() {
    local msg="$1"
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    
    if [[ $LOG_LEVEL_DEBUG -le $CURRENT_LOG_LEVEL ]]; then
        echo -e "${COLOR_BLUE}[DEBUG]${COLOR_NC} $timestamp - $msg"
        echo "[DEBUG] $timestamp - $msg" >> "$LOG_FILE"
    fi
}

# Example usage
log_info "Script starting"
log_debug "Debug information"

if [[ ! -f "/etc/config" ]]; then
    log_error "Configuration file not found: /etc/config"
    exit 1
fi

log_info "Script completed successfully"
Professional logging system

4. Complete Project: Robust Backup Script

#!/bin/bash
# robust_backup.sh - Production backup with error handling

# Safety options
set -euo pipefail

# Configuration
BACKUP_SOURCE="${1:-/home/user/data}"
BACKUP_DEST="${2:-/backup}"
LOG_FILE="/var/log/backup.log"
LOCK_FILE="/var/run/backup.lock"
MAX_BACKUPS=10

# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

# Logging function
log() {
    local level="$1"
    local message="$2"
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE"
}

# Trap cleanup
cleanup() {
    if [[ -f "$LOCK_FILE" ]]; then
        log "INFO" "Removing lock file..."
        rm -f "$LOCK_FILE"
    fi
    log "INFO" "Backup script ended"
}
trap cleanup EXIT INT TERM

# Error handler
error_handler() {
    local line=$1
    local command=$2
    local code=$3
    log "ERROR" "Command '$command' failed at line $line with exit code $code"
    log "ERROR" "Backup aborted"
    exit 1
}
trap 'error_handler ${LINENO} "$BASH_COMMAND" $?' ERR

# Check if script is already running
if [[ -f "$LOCK_FILE" ]]; then
    local pid=$(cat "$LOCK_FILE")
    if kill -0 "$pid" 2>/dev/null; then
        log "ERROR" "Backup already running with PID $pid"
        exit 1
    else
        log "WARNING" "Stale lock file found, removing..."
        rm -f "$LOCK_FILE"
    fi
fi

# Create lock file
echo $$ > "$LOCK_FILE"
log "INFO" "Backup started with PID $$"

# Validate source
if [[ ! -d "$BACKUP_SOURCE" ]]; then
    log "ERROR" "Source directory does not exist: $BACKUP_SOURCE"
    exit 1
fi

# Create destination if it doesn't exist
if [[ ! -d "$BACKUP_DEST" ]]; then
    log "INFO" "Creating destination directory: $BACKUP_DEST"
    mkdir -p "$BACKUP_DEST"
fi

# Create backup filename
BACKUP_NAME="backup_$(basename "$BACKUP_SOURCE")_$(date +%Y%m%d_%H%M%S).tar.gz"
BACKUP_PATH="$BACKUP_DEST/$BACKUP_NAME"

# Perform backup
log "INFO" "Starting backup of $BACKUP_SOURCE"
log "INFO" "Destination: $BACKUP_PATH"

tar -czf "$BACKUP_PATH" -C "$(dirname "$BACKUP_SOURCE")" "$(basename "$BACKUP_SOURCE")"

if [[ $? -eq 0 ]]; then
    log "INFO" "Backup completed successfully"
    BACKUP_SIZE=$(du -h "$BACKUP_PATH" | cut -f1)
    log "INFO" "Backup size: $BACKUP_SIZE"
else
    log "ERROR" "Backup failed"
    exit 1
fi

# Rotate old backups
log "INFO" "Rotating old backups (keeping $MAX_BACKUPS)"
BACKUP_COUNT=$(ls -1 "$BACKUP_DEST"/backup_*.tar.gz 2>/dev/null | wc -l)

if [[ $BACKUP_COUNT -gt $MAX_BACKUPS ]]; then
    TO_DELETE=$((BACKUP_COUNT - MAX_BACKUPS))
    log "INFO" "Removing $TO_DELETE old backups"
    ls -1t "$BACKUP_DEST"/backup_*.tar.gz | tail -n "$TO_DELETE" | while read -r file; do
        log "INFO" "Removing old backup: $(basename "$file")"
        rm -f "$file"
    done
fi

# Verify backup
log "INFO" "Verifying backup integrity"
if tar -tzf "$BACKUP_PATH" > /dev/null 2>&1; then
    log "INFO" "Backup verification successful"
    echo -e "${GREEN}✓ Backup completed successfully!${NC}"
    echo "  File: $BACKUP_PATH"
    echo "  Size: $BACKUP_SIZE"
else
    log "ERROR" "Backup verification failed - backup may be corrupted"
    exit 1
fi

log "INFO" "Backup script completed"
exit 0
Production-ready backup script with full error handling

5. Common Errors & Solutions

6. Summary Checklist

7. Next Steps

Next lesson: Bash Lesson 2.5: Automation with Cron & Systemd Timers

You'll learn to:

  • Schedule scripts with crontab
  • Use systemd timers
  • Monitor scheduled jobs
  • Handle job failures

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen January 22, 2025

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment