Bash Scripting

Why This Matters

You have been typing commands one at a time. That works fine when you are doing something once. But what happens when you need to do the same thing every day? Or on fifty servers? Or in a CI/CD pipeline at 2 AM with nobody at the keyboard?

You write a script.

A Bash script is just a text file full of commands that Bash executes in sequence. But scripts can also make decisions, loop over data, accept arguments, handle errors, and call functions. A well-written script can replace hours of manual work with a single command.

This chapter takes you from writing your first script to writing robust, production-grade Bash that handles errors gracefully, parses command-line arguments, and does not break when the unexpected happens.


Try This Right Now

Create and run your first script:

cat > /tmp/hello.sh << 'SCRIPT'
#!/bin/bash
echo "Hello, $(whoami)!"
echo "Today is $(date +%A), $(date +%B) $(date +%d)"
echo "You are running Bash $BASH_VERSION"
echo "Your system has $(nproc) CPU cores"
echo "Free memory: $(free -h | awk '/Mem:/ {print $4}')"
SCRIPT
chmod +x /tmp/hello.sh
/tmp/hello.sh

You should see a personalized system summary. That is a script: commands in a file, executed as a unit.


The Shebang Line

Every script should start with a shebang (#!) that tells the system which interpreter to use:

#!/bin/bash

Common shebangs:

#!/bin/bash        # Use Bash specifically
#!/bin/sh          # Use the system's POSIX shell (might not be Bash)
#!/usr/bin/env bash  # Find bash in PATH (more portable)
#!/usr/bin/env python3  # For Python scripts

Why does this matter? Without a shebang, the script is run by whatever shell invoked it. With a shebang, it always runs with the correct interpreter, even when called from a different shell.

# Make a script executable and run it
chmod +x myscript.sh
./myscript.sh        # Uses the shebang interpreter

# Or explicitly invoke bash (shebang is ignored)
bash myscript.sh

Distro Note: On some systems (FreeBSD, certain containers), Bash is not at /bin/bash. Using #!/usr/bin/env bash is more portable because it searches $PATH.


Exit Codes

Every command returns an exit code: an integer from 0 to 255.

# 0 means success
ls /tmp
echo $?    # 0

# Non-zero means failure
ls /nonexistent
echo $?    # 2

# You can set your own exit code
exit 0     # Success
exit 1     # General error
exit 2     # Misuse of command

Conventional exit codes:

CodeMeaning
0Success
1General error
2Misuse of command/arguments
126Command found but not executable
127Command not found
128+NKilled by signal N (e.g., 137 = killed by SIGKILL)

Using exit codes in scripts:

#!/bin/bash
if [ ! -f /etc/hosts ]; then
    echo "ERROR: /etc/hosts not found" >&2
    exit 1
fi
echo "OK: /etc/hosts exists"
exit 0

Conditionals

The if Statement

#!/bin/bash

if [ -f /etc/hosts ]; then
    echo "/etc/hosts exists"
elif [ -f /etc/hostname ]; then
    echo "/etc/hostname exists"
else
    echo "Neither file found"
fi

test, [ ], and [[ ]]

There are three ways to test conditions:

# These are equivalent:
test -f /etc/hosts
[ -f /etc/hosts ]

# [[ ]] is a Bash enhancement (preferred):
[[ -f /etc/hosts ]]

Why prefer [[ ]]?

  • No word splitting on variables (safer)
  • Supports && and || inside the brackets
  • Supports pattern matching with == and regex with =~
  • Does not need quoting on variables (though quoting is still good practice)

File Tests

[[ -f /path ]]    # True if file exists and is a regular file
[[ -d /path ]]    # True if directory exists
[[ -e /path ]]    # True if anything exists at that path
[[ -r /path ]]    # True if readable
[[ -w /path ]]    # True if writable
[[ -x /path ]]    # True if executable
[[ -s /path ]]    # True if file exists and is not empty
[[ -L /path ]]    # True if symbolic link

String Tests

[[ -z "$str" ]]            # True if string is empty
[[ -n "$str" ]]            # True if string is NOT empty
[[ "$a" == "$b" ]]         # True if strings are equal
[[ "$a" != "$b" ]]         # True if strings are not equal
[[ "$a" < "$b" ]]          # True if a sorts before b
[[ "$str" == *.txt ]]      # Pattern matching (glob)
[[ "$str" =~ ^[0-9]+$ ]]   # Regex matching

Integer Comparisons

[[ $a -eq $b ]]    # Equal
[[ $a -ne $b ]]    # Not equal
[[ $a -lt $b ]]    # Less than
[[ $a -le $b ]]    # Less than or equal
[[ $a -gt $b ]]    # Greater than
[[ $a -ge $b ]]    # Greater than or equal

# Or use (( )) for arithmetic comparisons (more readable):
(( a == b ))
(( a != b ))
(( a < b ))
(( a > b ))

Combining Conditions

# AND
[[ -f /etc/hosts && -r /etc/hosts ]]

# OR
[[ -f /etc/hosts || -f /etc/hostname ]]

# NOT
[[ ! -f /etc/hosts ]]

# Complex combination
if [[ -f "$config" && -r "$config" ]] && command -v jq &>/dev/null; then
    echo "Config file is readable and jq is available"
fi

Think About It: Why is [[ -f "$file" ]] safer than [ -f $file ] when $file might contain spaces or be empty?


Loops

for Loops

# Over a list
for color in red green blue; do
    echo "Color: $color"
done

# Over command output
for user in $(cut -d: -f1 /etc/passwd | head -5); do
    echo "User: $user"
done

# Over files (use glob, not ls!)
for f in /etc/*.conf; do
    echo "Config: $f"
done

# C-style for loop
for ((i=1; i<=5; i++)); do
    echo "Iteration $i"
done

# Over a range
for i in {1..10}; do
    echo "Number $i"
done

while Loops

# Basic while
count=1
while [[ $count -le 5 ]]; do
    echo "Count: $count"
    ((count++))
done

# Read a file line by line (correct way)
while IFS= read -r line; do
    echo "Line: $line"
done < /etc/hostname

# Read command output line by line
ps aux | while IFS= read -r line; do
    echo "$line"
done

# Infinite loop (useful for daemons, menus)
while true; do
    echo "Working... (Ctrl+C to stop)"
    sleep 5
done

until Loops

Like while, but runs until the condition becomes true:

count=1
until [[ $count -gt 5 ]]; do
    echo "Count: $count"
    ((count++))
done

Loop Control

# break -- exit the loop
for i in {1..100}; do
    if [[ $i -eq 5 ]]; then
        break
    fi
    echo $i
done
# Prints: 1 2 3 4

# continue -- skip to next iteration
for i in {1..10}; do
    if (( i % 2 == 0 )); then
        continue
    fi
    echo $i
done
# Prints: 1 3 5 7 9

case Statements

Pattern matching for cleaner multi-branch logic:

#!/bin/bash
case "$1" in
    start)
        echo "Starting service..."
        ;;
    stop)
        echo "Stopping service..."
        ;;
    restart)
        echo "Restarting service..."
        ;;
    status)
        echo "Service status: running"
        ;;
    *)
        echo "Usage: $0 {start|stop|restart|status}"
        exit 1
        ;;
esac

Cases support patterns:

case "$input" in
    [0-9]*)
        echo "Starts with a number"
        ;;
    *.txt|*.md)
        echo "Text-like file"
        ;;
    y|Y|yes|YES)
        echo "Affirmative"
        ;;
    *)
        echo "Unknown input"
        ;;
esac

Functions

#!/bin/bash

# Define a function
greet() {
    local name="$1"
    local time_of_day="$2"
    echo "Good $time_of_day, $name!"
}

# Call the function
greet "Alice" "morning"
greet "Bob" "evening"

Function Return Values

Functions use return for exit codes (0-255), not for returning data:

is_root() {
    [[ $(id -u) -eq 0 ]]
    return $?
}

if is_root; then
    echo "Running as root"
else
    echo "Not root"
fi

To return data, use echo (command substitution) or a global variable:

# Method 1: echo + command substitution (preferred)
get_hostname() {
    echo "$(hostname -f)"
}
my_host=$(get_hostname)

# Method 2: global variable (use sparingly)
get_info() {
    RESULT_OS=$(uname -s)
    RESULT_ARCH=$(uname -m)
}
get_info
echo "OS: $RESULT_OS, Arch: $RESULT_ARCH"

Local Variables

Always use local inside functions to avoid polluting the global scope:

bad_function() {
    counter=0    # GLOBAL -- bleeds into the caller's scope
}

good_function() {
    local counter=0    # LOCAL -- contained within the function
}

Here Documents

Here documents let you embed multi-line text in a script:

# Basic here document
cat << 'EOF'
This is a multi-line
text block. Variables like $HOME
are NOT expanded because we quoted 'EOF'.
EOF

# With expansion (no quotes on delimiter)
cat << EOF
Your home directory is: $HOME
Today is: $(date)
EOF

# Indented here document (<<- strips leading tabs)
if true; then
	cat <<- EOF
	This text can be indented with tabs
	and the tabs are stripped from output.
	EOF
fi

# Here string (single line)
grep "root" <<< "root:x:0:0:root:/root:/bin/bash"

Practical use -- creating a config file from a script:

cat > /tmp/myapp.conf << EOF
# Generated by setup script on $(date)
server_name=$HOSTNAME
listen_port=8080
log_level=info
EOF

Argument Parsing with getopts

For scripts that accept command-line flags:

#!/bin/bash

# Default values
verbose=false
output_file=""
count=1

# Parse options
while getopts "vo:c:h" opt; do
    case "$opt" in
        v) verbose=true ;;
        o) output_file="$OPTARG" ;;
        c) count="$OPTARG" ;;
        h)
            echo "Usage: $0 [-v] [-o output_file] [-c count] [files...]"
            exit 0
            ;;
        *)
            echo "Usage: $0 [-v] [-o output_file] [-c count] [files...]" >&2
            exit 1
            ;;
    esac
done

# Remove parsed options, leaving positional arguments
shift $((OPTIND - 1))

# Now "$@" contains the remaining arguments
echo "Verbose: $verbose"
echo "Output: $output_file"
echo "Count: $count"
echo "Remaining args: $@"

The option string "vo:c:h" means:

  • v -- flag (no argument)
  • o: -- option requiring an argument (the colon)
  • c: -- option requiring an argument
  • h -- flag (no argument)
./myscript.sh -v -o results.txt -c 5 file1.txt file2.txt
# Verbose: true
# Output: results.txt
# Count: 5
# Remaining args: file1.txt file2.txt

Defensive Scripting: set -euo pipefail

The single most important line you can add to any script:

#!/bin/bash
set -euo pipefail

What each flag does:

FlagBehavior
-eExit immediately if any command fails (non-zero exit code)
-uTreat unset variables as an error
-o pipefailA pipeline fails if any command in it fails (not just the last)

Why This Matters

Without set -e:

#!/bin/bash
cd /nonexistent        # Fails silently, cd does not happen
rm -rf *               # DELETES FILES IN THE CURRENT DIRECTORY!

With set -e:

#!/bin/bash
set -e
cd /nonexistent        # Script exits here with an error
rm -rf *               # Never reached

Without set -u:

#!/bin/bash
rm -rf "$DIERCTORY"/data    # Typo in variable name!
# Without -u, $DIERCTORY is empty, so this becomes: rm -rf /data

With set -u:

#!/bin/bash
set -u
rm -rf "$DIERCTORY"/data    # Script exits: DIERCTORY: unbound variable

WARNING: set -e can be surprising. It does not trigger on commands in if conditions, commands before ||, or commands in while conditions. Always test your error handling.

Handling Expected Failures with set -e

Sometimes a command is allowed to fail:

#!/bin/bash
set -euo pipefail

# Method 1: OR with true
grep "pattern" file.txt || true
# grep failing (no match) won't exit the script

# Method 2: Conditional
if grep -q "pattern" file.txt; then
    echo "Found it"
fi
# The if-condition doesn't trigger set -e

# Method 3: Explicit check
result=$(command_that_might_fail) || {
    echo "Command failed, but we'll continue"
}

Debugging with set -x

When a script does not behave as expected, set -x shows every command as it executes:

#!/bin/bash
set -x    # Enable trace mode

name="world"
echo "Hello, $name"
ls /tmp/*.txt 2>/dev/null | wc -l

Output:

+ name=world
+ echo 'Hello, world'
Hello, world
+ wc -l
+ ls '/tmp/a.txt' '/tmp/b.txt'
2

Each line prefixed with + is Bash showing you the command after expansion. This is invaluable for finding bugs.

You can also enable tracing for a section of a script:

#!/bin/bash
echo "Normal output"

set -x
# Traced section
result=$((2 + 3))
echo "Result: $result"
set +x

echo "Normal output again"

Using PS4 for Better Traces

Customize the trace prefix to show more context:

#!/bin/bash
export PS4='+ ${BASH_SOURCE}:${LINENO}: ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
set -x

my_function() {
    echo "Inside function"
}
my_function

Output:

+ myscript.sh:7: my_function()
+ myscript.sh:4: my_function(): echo 'Inside function'
Inside function

ShellCheck: Your Script Linter

ShellCheck is an open source static analysis tool that catches common Bash mistakes. Install it and use it on every script you write.

# Install
sudo apt install shellcheck        # Debian/Ubuntu
sudo dnf install ShellCheck        # Fedora
sudo pacman -S shellcheck          # Arch

Example: create a script with common mistakes:

cat > /tmp/buggy.sh << 'SCRIPT'
#!/bin/bash
echo $1
cd $dir
for f in $(ls *.txt); do
    cat $f
done
[ $var = "hello" ]
SCRIPT

shellcheck /tmp/buggy.sh

ShellCheck output:

In /tmp/buggy.sh line 2:
echo $1
     ^-- SC2086: Double quote to prevent globbing and word splitting.

In /tmp/buggy.sh line 3:
cd $dir
   ^--- SC2086: Double quote to prevent globbing and word splitting.
   ^--- SC2164: Use 'cd ... || exit' in case cd fails.

In /tmp/buggy.sh line 4:
for f in $(ls *.txt); do
         ^--------- SC2045: Iterating over ls output is fragile. Use globs.

In /tmp/buggy.sh line 5:
    cat $f
        ^-- SC2086: Double quote to prevent globbing and word splitting.

In /tmp/buggy.sh line 7:
[ $var = "hello" ]
  ^--- SC2086: Double quote to prevent globbing and word splitting.
  ^--- SC2154: var is referenced but not assigned.

Every warning is a real bug or potential bug. The fixed version:

#!/bin/bash
echo "$1"
cd "$dir" || exit 1
for f in *.txt; do
    cat "$f"
done
[[ "$var" == "hello" ]]

Think About It: Why does ShellCheck warn against for f in $(ls *.txt)? Think about what happens when filenames contain spaces, newlines, or special characters.


Hands-On: A Complete Script

Let us write a real-world script: a log analyzer that processes system logs.

cat > /tmp/log-analyzer.sh << 'MAINSCRIPT'
#!/bin/bash
set -euo pipefail

#-------------------------------------------------------
# log-analyzer.sh - Analyze system logs for SSH activity
#-------------------------------------------------------

# Default values
SINCE="today"
TOP_N=10
VERBOSE=false

# Colors (only if stdout is a terminal)
if [[ -t 1 ]]; then
    RED='\033[0;31m'
    GREEN='\033[0;32m'
    YELLOW='\033[0;33m'
    NC='\033[0m'  # No Color
else
    RED='' GREEN='' YELLOW='' NC=''
fi

usage() {
    cat << EOF
Usage: $(basename "$0") [OPTIONS]

Analyze system logs for SSH activity.

Options:
    -s SINCE    Time period (default: "today")
    -n NUM      Show top N results (default: 10)
    -v          Verbose output
    -h          Show this help message

Examples:
    $(basename "$0")
    $(basename "$0") -s "1 hour ago" -n 5
    $(basename "$0") -s "2025-03-10" -v
EOF
}

log_info() {
    echo -e "${GREEN}[INFO]${NC} $*"
}

log_warn() {
    echo -e "${YELLOW}[WARN]${NC} $*" >&2
}

log_error() {
    echo -e "${RED}[ERROR]${NC} $*" >&2
}

# Parse arguments
while getopts "s:n:vh" opt; do
    case "$opt" in
        s) SINCE="$OPTARG" ;;
        n) TOP_N="$OPTARG" ;;
        v) VERBOSE=true ;;
        h) usage; exit 0 ;;
        *) usage; exit 1 ;;
    esac
done
shift $((OPTIND - 1))

# Validate
if ! [[ "$TOP_N" =~ ^[0-9]+$ ]]; then
    log_error "Invalid number: $TOP_N"
    exit 1
fi

# Check if we can read the journal
if ! command -v journalctl &>/dev/null; then
    log_error "journalctl not found. Is systemd installed?"
    exit 1
fi

log_info "Analyzing SSH logs since: $SINCE"
echo ""

# Count total SSH log entries
total=$(journalctl -u sshd.service -u ssh.service \
    --since "$SINCE" --no-pager 2>/dev/null | wc -l || echo "0")
log_info "Total SSH log entries: $total"

# Count failed login attempts
failed=$(journalctl -u sshd.service -u ssh.service \
    --since "$SINCE" --no-pager 2>/dev/null \
    | grep -ci "failed\|invalid" || echo "0")
log_info "Failed login attempts: $failed"

# Count successful logins
success=$(journalctl -u sshd.service -u ssh.service \
    --since "$SINCE" --no-pager 2>/dev/null \
    | grep -ci "accepted" || echo "0")
log_info "Successful logins: $success"

echo ""
log_info "Top $TOP_N source IPs (failed attempts):"
echo "---"
journalctl -u sshd.service -u ssh.service \
    --since "$SINCE" --no-pager 2>/dev/null \
    | grep -i "failed" \
    | grep -oP 'from \K[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \
    | sort | uniq -c | sort -rn | head -"$TOP_N" || true
echo ""

if $VERBOSE; then
    log_info "Last 5 SSH events:"
    echo "---"
    journalctl -u sshd.service -u ssh.service \
        --since "$SINCE" --no-pager -n 5 2>/dev/null || true
fi

log_info "Analysis complete."
MAINSCRIPT

chmod +x /tmp/log-analyzer.sh

Test it:

/tmp/log-analyzer.sh -h
/tmp/log-analyzer.sh -v
/tmp/log-analyzer.sh -s "1 week ago" -n 5

Debug This: Script Fails Silently

Someone wrote this script, but it produces no output and exits with code 0:

#!/bin/bash
set -euo pipefail

LOGFILE="/var/log/myapp/app.log"

# Count errors in the log
error_count=$(grep -c "ERROR" $LOGFILE)
echo "Found $error_count errors"

What is wrong?

  1. $LOGFILE is unquoted (though this specific path has no spaces, it is still bad practice)
  2. If the file does not exist, grep fails with exit code 2, and set -e kills the script silently
  3. If the file exists but has no matches, grep -c returns exit code 1 (no match), and set -e kills the script

Fixed:

#!/bin/bash
set -euo pipefail

LOGFILE="/var/log/myapp/app.log"

if [[ ! -f "$LOGFILE" ]]; then
    echo "ERROR: Log file not found: $LOGFILE" >&2
    exit 1
fi

error_count=$(grep -c "ERROR" "$LOGFILE" || true)
echo "Found $error_count errors"

Script Template

Use this as a starting point for new scripts:

#!/bin/bash
set -euo pipefail

# Description: What this script does
# Usage: ./script.sh [-v] [-o output] <input>

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"

# Default values
VERBOSE=false
OUTPUT=""

usage() {
    cat << EOF
Usage: $SCRIPT_NAME [OPTIONS] <input>

Options:
    -v          Verbose output
    -o FILE     Output file
    -h          Show help
EOF
}

die() {
    echo "ERROR: $*" >&2
    exit 1
}

log() {
    if $VERBOSE; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
    fi
}

# Parse arguments
while getopts "vo:h" opt; do
    case "$opt" in
        v) VERBOSE=true ;;
        o) OUTPUT="$OPTARG" ;;
        h) usage; exit 0 ;;
        *) usage; exit 1 ;;
    esac
done
shift $((OPTIND - 1))

# Validate required arguments
if [[ $# -lt 1 ]]; then
    die "Missing required argument. Use -h for help."
fi

INPUT="$1"
log "Processing: $INPUT"

# Main logic here
echo "TODO: implement"

What Just Happened?

+------------------------------------------------------------------+
|                     CHAPTER 19 RECAP                              |
+------------------------------------------------------------------+
|                                                                  |
|  - Start scripts with #!/bin/bash and set -euo pipefail         |
|  - Exit codes: 0 = success, non-zero = failure                  |
|  - Conditionals: prefer [[ ]] over [ ]; use (( )) for math     |
|  - Loops: for, while, until; use globs not ls for files         |
|  - case statements for multi-branch pattern matching            |
|  - Functions: use local variables, return exit codes             |
|  - Here documents for multi-line text embedding                  |
|  - getopts for command-line argument parsing                     |
|  - set -x for debugging; PS4 for better traces                  |
|  - ShellCheck catches common bugs -- use it always              |
|  - Always quote variables: "$var" not $var                      |
|                                                                  |
+------------------------------------------------------------------+

Try This

Exercise 1: System Info Script

Write a script that displays: hostname, kernel version, uptime, CPU count, total/free memory, total/free disk space, and the number of logged-in users. Accept a -j flag that outputs everything as JSON.

Exercise 2: File Organizer

Write a script that takes a directory as an argument and organizes files into subdirectories by extension (e.g., txt/, pdf/, jpg/). Include a -n (dry run) flag that shows what would happen without actually moving files.

Exercise 3: Process Monitor

Write a script that checks if a given process name is running. If not, print a warning. Accept the process name as an argument. Add a -l (loop) flag that checks every 10 seconds.

Exercise 4: Validate and Fix

Run ShellCheck on every script in your home directory. Fix the top 3 most common warnings it finds.

Bonus Challenge

Write a deployment script that: (1) accepts -e for environment (staging/production), (2) validates prerequisites (git, rsync, ssh), (3) shows a confirmation prompt with what it will do, (4) simulates deploying by syncing a local directory to a remote path using rsync, and (5) logs everything to a timestamped file. Include proper error handling and rollback on failure.