Bash & Shell Scripting
Overview
Bash (Bourne Again SHell) is the default command interpreter on most Linux systems. It provides a powerful scripting language for automating tasks, gluing commands together, and building operational tooling. This reference covers the syntax, builtins, patterns, and idioms most commonly used in production scripts.
Script fundamentals
Shebang and options
A shebang on the first line tells the kernel which interpreter to run the script with. Using env keeps the script portable across systems that install Bash in different locations.
#!/usr/bin/env bash
Always start scripts with the env-based shebang for portability. Follow it with safety options:
#!/usr/bin/env bash
set -euo pipefail
| Option | Effect |
|---|---|
set -e | Exit immediately on any command returning non-zero. |
set -u | Treat unset variables as an error. |
set -o pipefail | Return the exit status of the first failing command in a pipeline. |
set -x | Print each command before executing (debug mode). |
Running scripts
Once saved, a script can be run several ways: make it executable and invoke it directly, pass it to an interpreter explicitly, or load it into your current shell.
chmod +x script.sh # make executable
./script.sh # run directly
bash script.sh # run with explicit interpreter
source script.sh # run in current shell (or `. script.sh`)
Variables
Assignment and expansion
Variables store values for later use and are expanded with $. These patterns cover assignment plus the most common parameter expansions — supplying defaults, reporting unset variables, and conditional replacement.
name="production" # no spaces around =
readonly PI=3.14159 # constant
echo "${name}" # always quote expansions to prevent word-splitting
echo "${name:-default}" # default if unset or empty
echo "${name:=default}" # assign default if unset
echo "${name:?error message}" # error if unset
echo "${name:+replacement}" # replace if set
String manipulation
Parameter expansion also manipulates strings directly — trimming prefixes and suffixes, slicing substrings, measuring length, and replacing text — all without external tools like sed.
file="backup.tar.gz"
echo "${file%.gz}" # remove shortest suffix → backup.tar
echo "${file%%.*}" # remove longest suffix → backup
echo "${file#*.}" # remove shortest prefix → tar.gz
echo "${file##*.}" # remove longest prefix → gz
echo "${#file}" # string length
echo "${file:0:6}" # substring: offset 0, length 6 → backup
echo "${file/tar/zip}" # replace first match → backup.zip.gz
echo "${file//./_}" # replace all matches → backup_tar_gz
Arrays
Arrays hold multiple values under one variable name — ideal for lists of hosts, files, or arguments. They support indexing, appending, and iteration.
hosts=("web1" "web2" "db1") # declare array
echo "${hosts[0]}" # first element
echo "${hosts[@]}" # all elements
echo "${#hosts[@]}" # array length
hosts+=("web3") # append
for host in "${hosts[@]}"; do # iterate
echo "Pinging $host"
done
Associative arrays (Bash 4+)
Associative arrays map string keys to values — Bash's equivalent of a hash table. Use them for lookups such as a service-name-to-port mapping.
declare -A ports
ports=([http]=80 [https]=443 [ssh]=22])
echo "${ports[http]}"
for key in "${!ports[@]}"; do
echo "$key → ${ports[$key]}"
done
Control flow
If statements
if branches on the exit status of a command or the result of a [[ ]] test. The elif and else clauses handle the subsequent cases.
if [[ "$status" == "running" ]]; then
echo "Service is up"
elif [[ "$status" == "degraded" ]]; then
echo "Service is degraded" >&2
else
echo "Service is down" >&2
exit 1
fi
Test operators
The [[ ]] builtin evaluates file, string, and numeric conditions. These are the operators you will reach for most often inside if and while.
[[ -f "/etc/hostname" ]] # file exists and is regular
[[ -d "/var/log" ]] # directory exists
[[ -x "/usr/bin/nginx" ]] # executable exists
[[ -z "$var" ]] # string is empty
[[ -n "$var" ]] # string is non-empty
[[ "$a" == "$b" ]] # string equality
[[ "$a" != "$b" ]] # string inequality
[[ "$a" =~ ^[0-9]+$ ]] # regex match
[[ $count -gt 10 ]] # integer greater-than
[[ $count -lt 100 ]] # integer less-than
Chaining operators
Chaining runs commands conditionally on one line: && executes the next command only on success, || only on failure, and ; regardless of the outcome.
command1 && command2 # command2 runs only if command1 succeeds
command1 || command2 # command2 runs only if command1 fails
command1 ; command2 # both run regardless
Case statements
A case statement matches a value against multiple patterns and runs the first branch that fits — often cleaner than long if/elif chains for dispatching on a command argument.
case "$action" in
start)
systemctl start nginx
;;
stop|halt)
systemctl stop nginx
;;
restart)
systemctl restart nginx
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac
Loops
Loops repeat a block of commands: iterate over a fixed list or numeric range, read input line by line, or keep looping until a condition is met.
# For loop over a list
for service in nginx postgresql redis; do
systemctl restart "$service"
done
# C-style for loop
for ((i=0; i<10; i++)); do
echo "Iteration $i"
done
# While loop
while IFS= read -r line; do
echo "Line: $line"
done < /etc/hosts
# Until loop
until ping -c1 "$host" &>/dev/null; do
echo "Waiting for $host..."
sleep 2
done
# Infinite loop with break
while true; do
process_next_job || break
done
Functions
Functions group reusable blocks of code under a name. Arguments arrive as $1, $2, and so on; use local to scope variables and return to set an exit status.
# Definition
log_info() {
local message="$1"
echo "[$(date +'%Y-%m-%d %H:%M:%S')] INFO $message"
}
log_error() {
local message="$1"
echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR $message" >&2
}
# Usage
log_info "Starting backup process"
log_error "Backup directory not found"
# Return values: use return for exit codes, echo for data
is_port_open() {
local host="$1"
local port="$2"
nc -z -w3 "$host" "$port" &>/dev/null
}
if is_port_open "localhost" 5432; then
echo "PostgreSQL is accepting connections"
fi
Input and output
Reading input
read captures input from the user at runtime, optionally with a prompt and silent entry for passwords. The while read pattern also processes a file line by line.
read -p "Enter database name: " db_name
read -sp "Enter password: " db_pass # silent input
echo
# Read file line by line
while IFS= read -r line; do
[[ -z "$line" || "$line" =~ ^# ]] && continue # skip blanks and comments
echo "$line"
done < config.conf
Redirection
Redirection controls where a command's output and error streams go — to files, devices, or nowhere at all — and where its standard input comes from.
command > file.txt # stdout to file (overwrite)
command >> file.txt # stdout to file (append)
command 2> error.log # stderr to file
command &> combined.log # both stdout and stderr
command > /dev/null 2>&1 # discard all output
command < input.txt # read stdin from file
Heredocs
A heredoc feeds a multi-line block of text into a command's stdin — convenient for generating configuration files. Quoting the delimiter stops the shell from expanding variables inside the block.
cat > /etc/nginx/sites-available/app <<'EOF'
server {
listen 80;
server_name example.com;
root /var/www/html;
}
EOF
Use 'EOF' (quoted) to prevent variable expansion; use EOF (unquoted) to allow it.
Process substitution
Process substitution <(...) presents a command's output as if it were a file. Use it when a tool expects a file path but you want to feed or compare live command output.
diff <(ls /etc) <(ls /etc.bak)
while read -r user uid; do ...; done < <(awk -F: '{print $1, $3}' /etc/passwd)
Common patterns
Trap for cleanup
A trap registers a command to run when the script exits or receives a signal — the standard way to remove temporary files and release resources automatically.
cleanup() {
rm -f /tmp/work.$$.*
echo "Cleaned up temporary files."
}
trap cleanup EXIT
Lock file for singleton
A lock file combined with flock guarantees that only one instance of a script runs at a time. The -n flag fails immediately if the lock is already held, so the second instance can exit with a message.
LOCKFILE="/var/run/$(basename "$0").lock"
exec 200>"$LOCKFILE"
flock -n 200 || { echo "Another instance is running."; exit 1; }
Retry with backoff
This helper retries a failing command up to a fixed number of times, sleeping between attempts — useful for operations that fail intermittently, such as reaching an external API.
retry() {
local max_attempts="$1"
local delay="$2"
shift 2
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
if "$@"; then
return 0
fi
echo "Attempt $attempt failed. Retrying in ${delay}s..." >&2
sleep "$delay"
attempt=$((attempt + 1))
done
return 1
}
retry 5 10 curl -sSf https://api.example.com/health
Argument parsing
getopts parses command-line flags portably. This pattern collects options into variables, validates the required ones, and prints a usage message when they are missing.
usage() {
echo "Usage: $0 -h <host> -p <port> [-t <timeout>]" >&2
exit 1
}
host=""
port=""
timeout=30
while getopts "h:p:t:" opt; do
case "$opt" in
h) host="$OPTARG" ;;
p) port="$OPTARG" ;;
t) timeout="$OPTARG" ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
[[ -z "$host" || -z "$port" ]] && usage
Subshells and command substitution
Command substitution $(...) captures a command's output into a variable. Parenthesized commands run in a subshell, so changes like cd do not affect the calling script.
current_branch=$(git rev-parse --abbrev-ref HEAD)
file_count=$(find /var/log -type f -name "*.log" | wc -l)
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
# Run in a subshell to avoid cd side-effects
(cd /tmp && tar -czf backup.tar.gz /etc)
Debugging
These techniques find script bugs before they reach production: check syntax without running, trace every command as it executes, and print the failing line and command.
bash -n script.sh # syntax check (no execution)
bash -x script.sh # trace execution
bash -v script.sh # verbose mode
# Inline debug markers
set -x # enable tracing
critical_command
set +x # disable tracing
# Print stack trace on error
trap 'echo "Error at line $LINENO: $BASH_COMMAND"' ERR
Useful one-liners
A handful of shell shortcuts and one-liners speed up everyday interactive work.
# Run last command as root
sudo !!
# Repeat last command with substitution
^old^new
# Find large files
find / -type f -size +1G 2>/dev/null
# Check exit code of last command
echo $?
# Background a running foreground job
Ctrl+Z then bg
# Run command immune to hangups
nohup long_running_script.sh &