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 bashis 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:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of command/arguments |
| 126 | Command found but not executable |
| 127 | Command not found |
| 128+N | Killed 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$filemight 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 argumenth-- 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:
| Flag | Behavior |
|---|---|
-e | Exit immediately if any command fails (non-zero exit code) |
-u | Treat unset variables as an error |
-o pipefail | A 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 -ecan be surprising. It does not trigger on commands inifconditions, commands before||, or commands inwhileconditions. 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?
$LOGFILEis unquoted (though this specific path has no spaces, it is still bad practice)- If the file does not exist,
grepfails with exit code 2, andset -ekills the script silently - If the file exists but has no matches,
grep -creturns exit code 1 (no match), andset -ekills 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.