Linux: Shell Scripting with Bash
Master Bash automation from first principles to production pipelines. Learn variable parameter expansions, structured control flow, modular functions, strict error handling with set -euo pipefail, exit traps, and practical system automation scripts.
10.1 Your First Script
A shell script is an executable plain text file containing a sequence of commands executed line by line by the shell interpreter. Shell scripts automate administrative tasks, scheduled maintenance, software builds, and incident responses.
10.1.1 The Shebang Line and Executable Permissions
When a script file is executed directly from the terminal, the operating system kernel inspects the first two bytes of the file for the magic number sequence #! (shebang). This line specifies the absolute path to the binary interpreter:
Shebang and Permissions Workflow
- #!/usr/bin/env bash:The recommended portable shebang. It searches the current user's
PATHto locate the Bash executable, accommodating differing binary locations across Linux distributions (e.g.,/bin/bashvs/usr/local/bin/bash). - chmod +x script.sh:Grants execute permissions to the file. Without execute bits, the kernel rejects direct execution with a 'Permission denied' error.
- ls -l script.sh:Confirms permissions display
-rwxr-xr-xbefore executing.
10.1.2 Running Scripts and Exit Codes
Scripts are launched in multiple ways depending on directory location and permission state:
| Invocation Method | Permission Needed | Execution Context | Notes |
|---|---|---|---|
| ./script.sh | Execute (+x) | New child subshell | Requires relative path ./ because current directory is not in PATH |
| bash script.sh | Read (+r) only | Explicit Bash subshell | Runs without execute bit; useful for testing scripts under development |
| source script.sh (or . script.sh) | Read (+r) only | Current interactive shell | Variables, functions, and exports persist inside caller environment |
Exit Codes and Status Evaluation
- Exit Code 0 (Success):By POSIX convention, an exit status of
0signifies clean completion with zero errors. - Exit Codes 1 - 255 (Failure):Any non-zero exit status denotes an error (e.g.,
1for general error,2for misuse of builtins,127for command not found). - Inspect Exit Status with $?:The special parameter
$?holds the integer exit status of the most recently executed foreground pipeline. - Explicit Exit: exit [n]:Terminates script execution immediately with the supplied integer return status.
10.1.3 Comments and Script Formatting
Readable scripts improve maintainability. Use structured headers, meaningful comments prefixed with #, two-space indentation, and lowercase variable names for local script state to prevent colliding with exported shell environment variables:
system_report.sh
A documented script header. Load it, then watch it run.
#!/usr/bin/env bash # ============================================================================== # Script Name : system_report.sh # Purpose : Generate host diagnostic overview # Target OS : Ubuntu 24.04 LTS / Debian 12 / Rocky Linux 9 # ============================================================================== # Variables (lowercase for internal script variables) report_date=$(date '+%Y-%m-%d %H:%M:%S') hostname_str=$(hostname -f 2>/dev/null || hostname) echo "--- System Report generated on: $report_date ---" echo "Hostname : $hostname_str" echo "Kernel : $(uname -r)" echo "Uptime : $(uptime -p)" exit 0
10.2 Variables and Input
Variables store runtime data, user parameters, configuration flags, and captured command outputs. Bash does not require type declarations; all variables are treated as strings unless evaluated inside arithmetic contexts.
10.2.1 Variable Assignment, Quoting and Command Substitution
Variable Assignment Rules
- No Whitespace Around =:Write
target_dir="/var/log". Writingtarget_dir = "/var/log"causes Bash to attempt executingtarget_diras a command. - Double Quotes ("$var"): Performs variable interpolation and command substitution while preserving internal whitespace and preventing unwanted word-splitting or globbing.
- Single Quotes ('$var'): Treats every character literally without any variable interpolation (e.g.,
'$var'outputs literal$var). - Command Substitution $(command):Executes a command in a subshell and assigns its standard output:
current_ip=$(hostname -I | awk '{print $1}'). Prefer$(...)over legacy backticks`...`because$(...)nests cleanly.
10.2.2 User Input and Positional Parameters
Scripts accept input interactively through read or non-interactively via CLI arguments called positional parameters:
| Parameter | Meaning | Practical Example |
|---|---|---|
| $0 | The path or filename used to invoke the script | echo "Usage: $0 <filename>" |
| $1, $2 ... ${10} | Positional arguments; arguments 10 and above require curly braces | backup_target="$1" |
| $# | Total number of positional arguments passed to the script | if [ "$#" -lt 1 ]; then exit 1; fi |
| "$@" | All positional arguments expanded as individual quoted words | for file in "$@"; do process "$file"; done |
| "$*" | All positional arguments joined into a single word separated by first character of IFS | echo "Full argument string: $*" |
| $$ | Process ID (PID) of the currently executing script | lock_file="/tmp/job_$$.lock" |
Interactive Input with read
- read -r -p "Prompt: " variable:Prompts the user for text input. The
-rflag prevents backslashes from acting as escape characters. - read -s -p "Password: " secret:Silent mode (
-s) hides characters from the terminal display during password input. - read -t 10 -p "Continue [y/N]? " confirm:Timeout mode (
-t 10) aborts or falls back after 10 seconds of inactivity.
10.2.3 Arithmetic and String Parameter Operations
Bash includes native 64-bit integer arithmetic evaluation via $(( ... )) and built-in parameter expansion for string slicing, pattern replacement, and default fallbacks without calling external tools like sed or awk:
Arithmetic and string operations
Parameter expansion without sed or awk.
# Integer arithmetic: $(( expression ))
num_cores=4
memory_gb=16
threads_per_core=$(( (memory_gb * 1024) / num_cores ))
echo "Calculated metric: $threads_per_core"
# String Length: ${#variable}
filepath="/etc/nginx/nginx.conf"
echo "String length: ${#filepath}" # 20
# Substring Extraction: ${variable:offset:length}
echo "Prefix: ${filepath:0:10}" # /etc/nginx
# Strip matching prefix from front: ${variable#pattern} (short) / ${variable##pattern} (greedy)
filename="${filepath##*/}" # nginx.conf
# Strip matching suffix from end: ${variable%pattern} (short) / ${variable%%pattern} (greedy)
extension="${filename##*.}" # conf
base_name="${filename%.*}" # nginx
# Pattern replacement: ${variable//pattern/replacement}
path_dashed="${filepath//\//-}" # -etc-nginx-nginx.conf
# Default fallback values: ${variable:-default}
port="${APP_PORT:-8080}" # uses 8080 if APP_PORT is unset or empty10.3 Control Flow
Control flow statements guide conditional branch logic, evaluate filesystem attributes, pattern-match CLI arguments, and loop over collections.
10.3.1 Conditional Statements with [[ ... ]]
Always prefer the modern compound command [[ ... ]] over the legacy POSIX [ ... ] (test) command. It avoids syntax crashes from unquoted empty variables, supports standard logical operators (&&, ||), and supports regular expression matching with =~:
| Test Operator | Category | Evaluation Condition | Example Expression |
|---|---|---|---|
| -f file | Filesystem | True if path exists and is a regular file | [[ -f "/etc/passwd" ]] |
| -d dir | Filesystem | True if path exists and is a directory | [[ -d "/var/log" ]] |
| -s file | Filesystem | True if path exists and file size is greater than zero | [[ -s "/tmp/output.log" ]] |
| -z str / -n str | String | -z true if length is zero; -n true if length is non-zero | [[ -z "$user_input" ]] |
| == / != | String | String equality and inequality | [[ "$env" == "production" ]] |
| -eq, -ne, -lt, -ge | Integer | Numeric comparisons: equal, not equal, less than, greater/equal | [[ "$status_code" -eq 200 ]] |
| =~ regex | Pattern | Matches string against an Extended Regular Expression | [[ "$email" =~ ^[A-Za-z0-9._%+-]+@ ]] |
Conditional directory check
if / elif / else with [[ ... ]].
if [[ ! -d "$backup_dir" ]]; then echo "Target directory does not exist. Creating..." mkdir -p "$backup_dir" elif [[ ! -w "$backup_dir" ]]; then echo "Error: Directory is not writable: $backup_dir" >&2 exit 1 else echo "Backup destination verified: $backup_dir" fi
10.3.2 Pattern Matching with case Statements
Use case ... in ... esac to evaluate an expression against multiple wildcard patterns cleanly, eliminating long chains of elif statements:
Case Subcommand Handler
- Pattern Wildcards:Use pipe
|for OR logic (start|reload),*as a catch-all fallback, and terminate each clause with double semicolons;;. - Service Control Pattern:Standard architecture for system init scripts, backup runners, and CLI subcommand dispatchers.
case subcommand handler
Runs with no argument, so it prints the usage line.
action="${1:-}"
case "$action" in
start)
echo "Starting application daemon..."
systemctl start myapp.service
;;
stop)
echo "Stopping application daemon..."
systemctl stop myapp.service
;;
restart|reload)
echo "Restarting application daemon..."
systemctl restart myapp.service
;;
status)
systemctl status myapp.service
;;
*)
echo "Usage: $0 {start|stop|restart|status}" >&2
exit 1
;;
esac10.3.3 Loops: for, while, and until
Looping Constructs
- For-In Loop: for item in "${list[@]}"; do:Iterates over arrays, globbed files, or whitespace-delimited word lists.
- C-Style For Loop: for ((i=0; i<10; i++)); do:Performs numeric counter iteration with initialization, condition, and increment expressions.
- While-Read Loop: while IFS= read -r line; do:The standard mechanism for reading text files line-by-line safely without losing leading/trailing whitespace or misinterpreting backslashes.
- Until Loop: until ping -c 1 "$host" &>/dev/null; do:Continues looping until the command condition returns exit code 0 (ideal for polling host availability).
while-read loop
Reads /etc/hosts line by line.
# Safe line-by-line file processing with while-read while IFS= read -r host_entry || [[ -n "$host_entry" ]]; do # Skip comments and empty lines [[ -z "$host_entry" || "$host_entry" =~ ^# ]] && continue echo "Pinging: $host_entry" ping -c 1 -W 2 "$host_entry" &>/dev/null && echo " [ONLINE]" || echo " [OFFLINE]" done < /etc/hosts
10.4 Functions and Arrays
Deconstruct complex procedural tasks into modular, reusable functions and manage collections of items using indexed and associative arrays.
10.4.1 Defining Functions and Local Variable Scoping
Define functions with the syntax function_name() { ... }. Functions receive arguments through positional parameters ($1, $2) scoped to the function body. Always declare internal function variables with local to prevent mutating global script state:
Function Scope and Return Codes
- local variable_name:Restricts variable scope strictly to the enclosing function stack frame.
- return [0-255]:Returns an integer status code to the caller (checked via
$?). Functions cannot directly return strings viareturn. - String Output via echo / printf:To return strings, write to standard output inside the function and capture the call with command substitution:
result=$(my_func).
Function with local variables
A reusable log() helper.
log() {
local level="$1"
shift # Removes level, leaving rest of arguments as message
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "[%s] [%-5s] %s\n" "$timestamp" "$level" "$message"
}
log "INFO" "Deployment sequence initiated"
log "WARN" "Memory threshold reached 80%"10.4.2 Indexed and Associative Arrays
Array Data Structures
- Indexed Arrays (arr=("one" "two")):Integer-indexed collections. Append elements with
arr+=("three"). Length is retrieved via${#arr[@]}. All elements are referenced with"${arr[@]}". - Associative Arrays (declare -A dict):Key-value hash maps. Must be declared explicitly with
declare -Abefore assignment:dict[port]=8080. Keys are retrieved with"${!dict[@]}".
Indexed and associative arrays
Iterate over both kinds of array.
# Indexed array
servers=("web01" "web02" "db01")
servers+=("cache01")
echo "Total server count: ${#servers[@]}"
for srv in "${servers[@]}"; do
echo "Target: $srv"
done
# Associative array (key-value hash map)
declare -A service_ports
service_ports[ssh]=22
service_ports[http]=80
service_ports[https]=443
service_ports[postgres]=5432
for proto in "${!service_ports[@]}"; do
echo "Port for $proto is ${service_ports[$proto]}"
done10.4.3 Sourcing External Files and Modular Libraries
Break monolithic scripts into modular components by separating configuration values, shared helper libraries, and business logic using source or .:
Sourcing a helper library
Creates lib/helpers.sh, then sources it.
# Resolve directory where the currently executing script resides
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
# Source common helper library
if [[ -f "${script_dir}/lib/helpers.sh" ]]; then
# shellcheck source=/dev/null
source "${script_dir}/lib/helpers.sh"
else
echo "Required library helpers.sh missing!" >&2
exit 1
fi10.5 Defensive Error Handling and Script Quality
By default, Bash continues executing subsequent lines even when intermediate commands fail, tolerates uninitialized variables, and masks pipeline errors. Applying strict flags and signal handlers protects production infrastructure from unexpected state corruption.
10.5.1 Strict Mode: set -euo pipefail
Place this header at the beginning of all production automation scripts:
| Flag | POSIX Name | Operational Mechanism | Critical Problem Prevented |
|---|---|---|---|
| set -e | errexit | Exit immediately if any command returns a non-zero exit status | Prevents running subsequent destructive commands if preceding setup fails |
| set -u | nounset | Treat unset or uninitialized variables as fatal errors and exit | Prevents disaster like rm -rf "$BACKUP_DIR/" expanding to rm -rf / if variable is unset |
| set -o pipefail | pipefail | Pipeline exit status reflects the rightmost command with a non-zero exit code | Prevents masking errors when a failed command pipes into grep or tee (which return 0) |
Safe Temporary Disabling of set -e
- Handling Expected Failures:If a command is allowed to return non-zero without terminating the script, append
|| true(e.g.,grep "pattern" file || true) or wrap it in anifblock. - Toggling errexit:Temporarily disable with
set +ebefore the command and restore withset -eimmediately afterward.
10.5.2 Exit and Signal Handling with trap
Use trap to guarantee resource cleanup (temporary directories, socket files, lockfiles) when a script terminates, whether by normal completion, error, or user interruption (Ctrl+C):
Trap Signal Handlers
- trap cleanup EXIT:The pseudo-signal
EXITfires whenever the script terminates under any circumstance (clean exit, error underset -e, or explicit exit call). - trap cleanup SIGINT SIGTERM:Handles manual cancellation (
Ctrl+C/ SIGINT) and service shutdown signals (SIGTERM).
Cleanup with trap
The EXIT trap removes the temp directory.
#!/usr/bin/env bash
set -euo pipefail
# Create secure unique temporary workspace
work_dir=$(mktemp -d /tmp/backup_job.XXXXXX)
cleanup() {
local exit_code=$?
echo "Cleaning up temporary files in $work_dir..."
rm -rf "$work_dir"
exit "$exit_code"
}
# Register cleanup handler for all exit and termination events
trap cleanup EXIT SIGINT SIGTERM
echo "Performing work inside $work_dir..."
# If an error happens below, cleanup still executes reliably10.5.3 Debugging Traces and ShellCheck Static Analysis
Diagnostic and Linting Tools
- Execution Tracing (set -x / bash -x):Prints each command preceded by
+to stderr after parameter expansion but before execution. Toggle inside scripts withset -xandset +x. - Syntax Validation (bash -n script.sh):Parses script grammar and syntax without executing any commands.
- ShellCheck Static Analysis:The industry-standard linter for shell scripts. Detects unquoted expansions, word-splitting bugs, portability pitfalls, and insecure patterns.
10.6 Practical Automation Scripts
Examine four standard production templates for core operational responsibilities: directory archiving, disk utilization alerts, bulk user account provisioning, and access log analysis.
10.6.1 Automated Backup and Retention Pruning Script
Creates timestamped tar archives of web application content and automatically deletes archives older than 7 days:
Backup and retention script
Creates a sample site, archives it, prunes old archives.
#!/usr/bin/env bash
set -euo pipefail
SRC_DIR="${1:-/var/www/html}"
BACKUP_DEST="/backup/daily"
RETENTION_DAYS=7
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
ARCHIVE_NAME="site_backup_${TIMESTAMP}.tar.gz"
if [[ ! -d "$SRC_DIR" ]]; then
echo "Error: Source directory does not exist: $SRC_DIR" >&2
exit 1
fi
mkdir -p "$BACKUP_DEST"
echo "Starting archive of $SRC_DIR..."
tar -czf "${BACKUP_DEST}/${ARCHIVE_NAME}" -C "$SRC_DIR" .
echo "Backup successfully created: ${BACKUP_DEST}/${ARCHIVE_NAME}"
# Prune archives older than retention window
echo "Pruning backups older than ${RETENTION_DAYS} days..."
find "$BACKUP_DEST" -type f -name "site_backup_*.tar.gz" -mtime "+${RETENTION_DAYS}" -delete
echo "Backup lifecycle tasks complete."10.6.2 Disk Space Monitoring and Alert Script
Monitors root filesystem utilization and writes warning events to syslog and stderr if usage crosses the danger threshold:
Disk usage alert
Warns when / is over the threshold.
#!/usr/bin/env bash
set -euo pipefail
ALERT_THRESHOLD=85
MOUNT_POINT="/"
# Extract usage percentage as integer
usage_pct=$(df -P "$MOUNT_POINT" | awk 'NR==2 {gsub("%","",$5); print $5}')
if [[ "$usage_pct" -ge "$ALERT_THRESHOLD" ]]; then
alert_msg="[CRITICAL] Filesystem $MOUNT_POINT usage is at ${usage_pct}% (Threshold: ${ALERT_THRESHOLD}%)"
echo "$alert_msg" >&2
logger -t disk_monitor -p user.crit "$alert_msg"
exit 2
fi
echo "[OK] Filesystem $MOUNT_POINT usage is nominal: ${usage_pct}% used."10.6.3 Batch User Account Provisioning Script
Parses a newline-delimited manifest of usernames, validates inputs, and idempotently provisions user accounts with home directories:
Batch user provisioning
Creates users listed in users.txt.
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <users_manifest.txt>" >&2
exit 1
fi
manifest_file="$1"
if [[ ! -r "$manifest_file" ]]; then
echo "Error: Cannot read manifest: $manifest_file" >&2
exit 1
fi
while IFS= read -r username || [[ -n "$username" ]]; do
# Trim whitespace, skip empty lines and comments
username=$(echo "$username" | tr -d '[:space:]')
[[ -z "$username" || "$username" =~ ^# ]] && continue
if id "$username" &>/dev/null; then
echo "Account '$username' already exists; skipping."
else
useradd -m -s /bin/bash "$username"
echo "Created user account: $username"
fi
done < "$manifest_file"10.6.4 Web Access Log Analysis Script
Aggregates the top 5 requesting client IP addresses and counts HTTP 5xx error statuses from an Nginx access log:
Access log analysis
Top client IPs and 5xx count from a sample log.
#!/usr/bin/env bash
set -euo pipefail
log_file="${1:-/var/log/nginx/access.log}"
if [[ ! -r "$log_file" ]]; then
echo "Error: Cannot read log file: $log_file" >&2
exit 1
fi
echo "=== Top 5 Requesting IP Addresses ==="
awk '{print $1}' "$log_file" | sort | uniq -c | sort -rn | head -n 5
echo ""
echo "=== HTTP 5xx Server Error Count ==="
awk '$9 ~ /^5/ {count++} END {print count+0}' "$log_file"Knowledge Check: Module 10 Shell Scripting with Bash
1. Which strict flag causes Bash to exit immediately if any command returns a non-zero exit status?
2. What is the correct syntax to test if a variable points to an existing directory?
3. How should command substitution be written in modern Bash scripts?
4. Which special parameter expands to the total count of positional arguments passed to a script?
5. Which trap pseudo-signal runs cleanup commands when a script finishes execution?
6. Which static analysis utility detects common bugs and non-portable syntax in Bash scripts?