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