Skip to main content
Navigation
HomeTechnical ReferenceJournalGitHubGitHub
Sidebar — toggle document categories via the logo
Categories

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
OptionEffect
set -eExit immediately on any command returning non-zero.
set -uTreat unset variables as an error.
set -o pipefailReturn the exit status of the first failing command in a pipeline.
set -xPrint 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 &

See also​