Skip to main content
Bash intermediate Lesson 20 of 22

Bash Script Patterns

Learn production-ready patterns — argument parsing with getopts, usage functions, loading config files, and script locking.

Script Skeleton

A consistent script structure makes your scripts easier to read, debug, and maintain over time. Every section has a clear purpose: the header documents the script, the constants section defines what cannot change, the logging section gives you uniform output, and main() keeps the entry point clearly separated from the helper functions.

#!/usr/bin/env bash
# =============================================================================
# script-name.sh
# Description: What this script does.
# Usage:       ./script-name.sh [OPTIONS] <argument>
# =============================================================================

set -euo pipefail
IFS=$'\n\t'

# --- Constants ---------------------------------------------------------------
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly VERSION="1.0.0"

# --- Defaults ----------------------------------------------------------------
VERBOSE=false
DRY_RUN=false
LOG_FILE=""

# --- Logging -----------------------------------------------------------------
log()   { printf '[%s] [INFO]  %s\n' "$(date '+%H:%M:%S')" "$*"; }
warn()  { printf '[%s] [WARN]  %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
error() { printf '[%s] [ERROR] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
debug() { $VERBOSE && printf '[%s] [DEBUG] %s\n' "$(date '+%H:%M:%S')" "$*" || true; }
die()   { error "$1"; exit "${2:-1}"; }

# --- Cleanup -----------------------------------------------------------------
cleanup() {
  local exit_code=$?
  debug "Exiting with code $exit_code"
  # Add cleanup here (temp files, lock files, etc.)
}
trap cleanup EXIT

# --- Usage -------------------------------------------------------------------
usage() {
  cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS] <argument>

Description here.

Options:
  -o, --output FILE   Output file
  -v, --verbose       Verbose output
  -n, --dry-run       Show what would be done, don't do it
  -h, --help          Show this help
  -V, --version       Show version

Examples:
  $SCRIPT_NAME input.txt
  $SCRIPT_NAME -v -o result.txt input.txt
EOF
}

# --- Main --------------------------------------------------------------------
main() {
  log "Starting $SCRIPT_NAME v$VERSION"
  # ... your logic here ...
  log "Done"
}

main "$@"

Argument Parsing with getopts

getopts is the POSIX-standard way to parse short options like -v, -o file. It handles option bundling (-vn), error reporting, and option-argument separation correctly — things that are easy to get wrong in a manual loop.

parse_args() {
  while getopts ":o:vnh" opt; do
    case "$opt" in
      o) OUTPUT_FILE="$OPTARG" ;;       # -o requires an argument
      v) VERBOSE=true ;;
      n) DRY_RUN=true ;;
      h) usage; exit 0 ;;
      :) die "Option -$OPTARG requires an argument" 2 ;;  # missing argument
      ?) die "Unknown option: -$OPTARG" 2 ;;              # unknown option
    esac
  done

  # Remove parsed options from $@ so positional arguments remain
  shift $(( OPTIND - 1 ))

  if [[ $# -lt 1 ]]; then
    die "Missing required argument. See --help." 2
  fi
  INPUT_FILE="$1"
}

parse_args "$@"

Argument Parsing — Long Options (manual)

For --verbose and --output=file style long options, parse manually with a while/case loop. This pattern is self-documenting and handles all the edge cases correctly.

parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -o|--output)
        [[ $# -gt 1 ]] || die "Option $1 requires a value"
        OUTPUT_FILE="$2"; shift 2 ;;
      --output=*)
        OUTPUT_FILE="${1#--output=}"; shift ;;  # strip the --output= prefix
      -v|--verbose)
        VERBOSE=true; shift ;;
      -n|--dry-run)
        DRY_RUN=true; shift ;;
      -h|--help)
        usage; exit 0 ;;
      -V|--version)
        echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
      --)
        shift; break ;;   # -- signals the end of options
      -*)
        die "Unknown option: $1" 2 ;;
      *)
        break ;;           # first non-option argument — stop parsing
    esac
  done

  # Any remaining arguments are positional
  POSITIONAL_ARGS=("$@")
}

Loading Config Files

Real scripts need configuration that can vary per environment without modifying the script itself. The standard pattern is a layered config system: hardcoded defaults, overridden by a system config file, overridden by a user config file, overridden by environment variables. Each layer overrides the previous.

# Config file format: KEY=VALUE (valid bash syntax)
# /etc/myapp/config.sh or ~/.myapp.conf

load_config() {
  local config_file="$1"

  if [[ ! -f "$config_file" ]]; then
    warn "Config file not found: $config_file — using defaults"
    return 0
  fi

  if [[ ! -r "$config_file" ]]; then
    die "Config file not readable: $config_file"
  fi

  # shellcheck source=/dev/null
  source "$config_file"
  log "Loaded config: $config_file"
}

# 1. Start with hardcoded defaults
DB_HOST="localhost"
DB_PORT="5432"

# 2. System config overrides defaults
load_config "/etc/myapp/config.sh"

# 3. User config overrides system config
load_config "${HOME}/.myapp.conf"

# 4. Environment variables override everything — the outermost layer
DB_HOST="${MYAPP_DB_HOST:-$DB_HOST}"
DB_PORT="${MYAPP_DB_PORT:-$DB_PORT}"

Script Locking

Cron jobs and daemons can start a second instance before the first has finished, leading to race conditions and corrupted state. flock acquires an exclusive lock atomically — if another instance already holds the lock, the new one exits immediately rather than running concurrently.

#!/usr/bin/env bash
set -euo pipefail

LOCKFILE="/var/run/myapp-job.lock"

# Method 1: wrap in crontab (simplest — no script changes needed)
# */5 * * * * flock -n /var/run/myjob.lock /usr/local/bin/myjob.sh

# Method 2: acquire lock inside the script
exec 9>"$LOCKFILE"
if ! flock -n 9; then
  echo "Another instance is already running (lock: $LOCKFILE)" >&2
  exit 0
fi

# Method 3: wait up to 30 seconds for the lock instead of failing immediately
if ! flock -w 30 9; then
  die "Could not acquire lock after 30 seconds"
fi

# From here: we hold the lock exclusively
echo "Running with exclusive lock..."
# The lock is released automatically when FD 9 is closed (script exits)

Idempotent Operations

Idempotency means running a script multiple times produces the same result as running it once. This property is critical for deployment scripts, provisioning scripts, and anything that might be retried after a failure. The principle is: check current state before making changes, and only change what needs changing.

# Idempotent directory creation — no error if it already exists
ensure_dir() {
  local dir="$1"
  local mode="${2:-755}"
  if [[ ! -d "$dir" ]]; then
    mkdir -p "$dir"
    chmod "$mode" "$dir"
    log "Created directory: $dir"
  else
    debug "Directory already exists: $dir"
  fi
}

# Idempotent symlink — only update if the target has changed
ensure_symlink() {
  local target="$1"
  local link="$2"

  if [[ -L "$link" ]] && [[ "$(readlink "$link")" == "$target" ]]; then
    debug "Symlink already correct: $link -> $target"
    return 0
  fi
  ln -sf "$target" "$link"
  log "Created symlink: $link -> $target"
}

# Idempotent line in file — only append if the line is not already there
ensure_line_in_file() {
  local line="$1"
  local file="$2"
  if ! grep -qxF "$line" "$file" 2>/dev/null; then
    echo "$line" >> "$file"
    log "Added line to $file: $line"
  fi
}

ensure_line_in_file "export PATH=/usr/local/bin:\$PATH" ~/.bashrc

Dry Run Mode

A dry run flag lets users preview what a script would do without actually doing it. This is a safety feature that makes destructive scripts much safer to use in production, because operators can verify the intended changes before committing.

DRY_RUN=false

# run() wraps every destructive command — in dry-run mode, it just prints
run() {
  if $DRY_RUN; then
    echo "[DRY RUN] $*"
  else
    "$@"
  fi
}

# Use run() for every command that modifies state
run cp -r ./dist/ /var/www/html/
run systemctl restart nginx
run rm -rf /var/cache/myapp/

# Parse --dry-run flag from arguments
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true

Progress Indicators

Progress indicators keep operators informed about long-running scripts instead of leaving them staring at a blank terminal wondering if the script is still working.

# Simple spinner for unknown-length operations
spinner() {
  local pid=$!
  local chars='|/-\'
  local i=0

  while kill -0 "$pid" 2>/dev/null; do
    printf '\r[%c] Processing...' "${chars:$(( i % ${#chars} )):1}"
    (( i++ ))
    sleep 0.1
  done
  printf '\r[done] Processing complete\n'
}

# Progress bar for operations with a known total
progress_bar() {
  local current="$1"
  local total="$2"
  local width=40

  local pct=$(( current * 100 / total ))
  local filled=$(( current * width / total ))
  local empty=$(( width - filled ))

  printf '\r[%s%s] %d%%' \
    "$(printf '#%.0s' $(seq 1 $filled))" \
    "$(printf ' %.0s' $(seq 1 $empty))" \
    "$pct"

  (( current >= total )) && echo ""
}

# Example usage
total=100
for (( i=1; i<=total; i++ )); do
  progress_bar "$i" "$total"
  sleep 0.05
done

What’s Next

The next tutorial covers real DevOps scripts — deploy scripts, backup scripts, log rotation, health checks, and CI/CD helpers.

Frequently Asked Questions

When should I use getopts vs manual argument parsing?
getopts handles short options (-v, -o file) reliably and is POSIX standard. For long options (--verbose, --output=file), parse manually or use getopt (external). For simple scripts, a manual while/case loop over $@ is often clearest.
How do I make my script idempotent?
Design each action to be safe to run multiple times. Check state before acting: create a dir only if it doesn't exist, insert a DB row only if the key is absent. Idempotent scripts can be safely re-run after failures.
What is a lockfile and why do I need it?
A lockfile prevents two instances of the same script from running simultaneously, which is critical for cron jobs and daemons. Use flock for atomic locking — a manual touch/check approach has a race condition.