Shell Scripts

Bash script structure, lifecycle progression, input validation, and production patterns.

Script Header

The strict-mode header — every script starts here, no exceptions
#!/bin/bash
set -euo pipefail

# -e: exit on first error
# -u: treat unset variables as errors
# -o pipefail: pipe fails if ANY stage fails, not just the last

Without pipefail, curl bad | jq . exits 0 because jq succeeds on empty input. The curl failure is silently swallowed.

Script with usage and argument parsing
#!/bin/bash
set -euo pipefail

usage() {
    cat <<EOF
Usage: $(basename "$0") [-v] [-o OUTPUT] <input-file>

Options:
    -v          Verbose output
    -o OUTPUT   Output file (default: stdout)
    -h          Show this help
EOF
    exit 1
}

verbose=false
output="/dev/stdout"

while getopts ":vo:h" opt; do
    case $opt in
        v) verbose=true ;;
        o) output="$OPTARG" ;;
        h) usage ;;
        :) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
        \?) echo "Unknown option -$OPTARG" >&2; usage ;;
    esac
done
shift $((OPTIND - 1))

[[ $# -lt 1 ]] && { echo "Error: input file required" >&2; usage; }
input="$1"
[[ -f "$input" ]] || { echo "Error: $input not found" >&2; exit 1; }

Lifecycle Pattern

Four-stage script progression
Stage 1: /tmp/experiment.sh       — throwaway, testing an idea
Stage 2: ~/scripts/staging/       — works, needs refinement
Stage 3: ~/scripts/               — tested, documented, versioned
Stage 4: ~/.local/bin/            — PATH-accessible, production
Stage 1 — quick experiment in /tmp/
cat > /tmp/check-ports.sh << 'EOF'
#!/bin/bash
for port in 22 80 443 8080; do
    timeout 2 bash -c "echo >/dev/tcp/localhost/$port" 2>/dev/null \
        && echo "Port $port: OPEN" \
        || echo "Port $port: CLOSED"
done
EOF
chmod +x /tmp/check-ports.sh
/tmp/check-ports.sh
Stage 4 — production script with logging and error handling
#!/bin/bash
set -euo pipefail

readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"

log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "$2" | tee -a "$LOG_FILE"; }
die() { log "ERROR" "$1"; exit "${2:-1}"; }

log "INFO" "Starting $SCRIPT_NAME"
trap 'log "ERROR" "Failed at line $LINENO"' ERR
trap 'log "INFO" "Finished"' EXIT

Idempotent Operations

Guard with grep — only add if not already present
entry="10.50.1.20 ise-01"
grep -qF "$entry" /etc/hosts || echo "$entry" | sudo tee -a /etc/hosts
Guard with command check — only install if missing
command -v jq &>/dev/null || sudo pacman -S --noconfirm jq

Input Validation

Validate required environment variables
: "${API_TOKEN:?ERROR: API_TOKEN not set}"
: "${API_URL:?ERROR: API_URL not set}"

The : is a no-op. ${var:?message} expands var if set and non-empty, otherwise prints message to stderr and exits (due to set -e).

Validate file arguments
input="${1:?Usage: $(basename "$0") <input-file>}"
[[ -f "$input" ]] || { echo "Not a file: $input" >&2; exit 1; }
[[ -r "$input" ]] || { echo "Not readable: $input" >&2; exit 1; }
[[ -s "$input" ]] || { echo "Empty file: $input" >&2; exit 1; }

Temporary Files and Cleanup

mktemp with trap — guaranteed cleanup
tmpfile=$(mktemp)
tmpdir=$(mktemp -d)
trap 'rm -f "$tmpfile"; rm -rf "$tmpdir"' EXIT

curl -sf https://api.example.com/data > "$tmpfile"
process_data "$tmpfile" "$tmpdir"
# Cleanup happens automatically on exit, error, or signal

Interactive Confirmation

Confirm before destructive action
confirm() {
    local prompt="${1:-Are you sure?}"
    read -rp "$prompt [y/N] " response
    [[ "$response" =~ ^[Yy]$ ]]
}

confirm "Delete all logs older than 30 days?" \
    && find /var/log -name '*.log' -mtime +30 -delete

Practical Patterns

Service health check — verify-before pattern
#!/bin/bash
set -euo pipefail

services=("nginx" "sshd" "docker")
failed=0

for svc in "${services[@]}"; do
    if systemctl is-active --quiet "$svc"; then
        printf "  %-15s %s\n" "$svc" "OK"
    else
        printf "  %-15s %s\n" "$svc" "FAILED"
        (( failed++ ))
    fi
done

exit "$failed"
Git multi-remote push
#!/bin/bash
set -euo pipefail

branch=$(git rev-parse --abbrev-ref HEAD)
for remote in $(git remote); do
    echo "Pushing to $remote/$branch..."
    git push "$remote" "$branch" &
done
wait
echo "All remotes updated"

See Also