Shell Scripting Patterns Best Practices Intermediate May 2026

Shell Scripting Real-World Script Patterns

The essential patterns that appear in every serious bash project — idempotent scripts, retry with backoff, canary deployments, blue-green switching, health-check polling, and the complete production script template.

Every mature bash codebase converges on the same handful of patterns. They're not clever tricks — they're battle-tested solutions to problems every production script encounters: "what if it runs twice?", "what if the network is flaky?", "how do we roll back?". This page consolidates the most important ones.

BASH
#!/usr/bin/env bash
# setup_server.sh — Idempotent server setup
# Can be run multiple times — only applies what's missing

install_package() {
  local pkg="${1}"
  if dpkg -l "${pkg}" &>/dev/null 2>&1; then
    echo "  ✔ ${pkg} already installed"
  else
    echo "  → Installing ${pkg}..."
    DEBIAN_FRONTEND=noninteractive apt-get install -y "${pkg}"
  fi
}

create_user() {
  local user="${1}"
  if id "${user}" &>/dev/null; then
    echo "  ✔ User ${user} exists"
  else
    echo "  → Creating user ${user}..."
    useradd -m -s /bin/bash "${user}"
  fi
}

ensure_dir() {
  local dir="${1}" owner="${2:-root}" mode="${3:-755}"
  mkdir -p "${dir}"
  chown "${owner}" "${dir}"
  chmod "${mode}" "${dir}"
  echo "  ✔ ${dir} (${owner} ${mode})"
}

install_package nginx
install_package mysql-client
create_user     vriddh
ensure_dir      /opt/myapp vriddh 755
ensure_dir      /var/log/myapp vriddh 750
BASH
#!/usr/bin/env bash
# Generic retry with exponential backoff

retry() {
  local max="${1}";   shift
  local delay="${1}"; shift
  local attempt=1

  until "$@"; do
    if (( attempt >= max )); then
      echo "  ERROR: Failed after ${max} attempts" >&2
      return 1
    fi
    echo "  Attempt ${attempt}/${max} failed — retrying in ${delay}s"
    sleep "${delay}"
    (( attempt++ ))
    (( delay = delay * 2 ))   # exponential backoff
  done
  echo "  ✔ Succeeded on attempt ${attempt}"
}

# Usage
retry 5 2 curl -sf https://api.example.com/health
retry 3 1 mysql -h prod-db-01 -e "SELECT 1"

# Retry with jitter (random delay — prevents thundering herd)
retry_jitter() {
  local max="${1}"; shift
  local base_delay="${1}"; shift
  local attempt=1

  until "$@"; do
    (( attempt >= max )) && return 1
    local jitter=$(( RANDOM % base_delay ))
    local wait=$(( base_delay * attempt + jitter ))
    echo "  Retry ${attempt}/${max} in ${wait}s"
    sleep "${wait}"
    (( attempt++ ))
  done
}
BASH
#!/usr/bin/env bash
# blue_green_deploy.sh — Zero-downtime deployment

set -euo pipefail

SYMLINK="/opt/myapp/current"
RELEASES_DIR="/opt/myapp/releases"
NEW_RELEASE="${RELEASES_DIR}/$(date +%Y%m%d_%H%M%S)"
KEEP_RELEASES=5

echo "Deploying to: ${NEW_RELEASE}"

# 1. Deploy new release to a fresh directory
mkdir -p "${NEW_RELEASE}"
rsync -av --exclude='.git' ./dist/ "${NEW_RELEASE}/"
echo "  ✔ Files deployed"

# 2. Run migrations / config setup
ln -sfn /etc/myapp/shared/.env "${NEW_RELEASE}/.env"
RELEASE_DIR="${NEW_RELEASE}" ./scripts/migrate.sh
echo "  ✔ Migrations complete"

# 3. Health check the new release BEFORE switching symlink
NEW_PORT=8081
OLD_PORT=8080
APP_ROOT="${NEW_RELEASE}" PORT="${NEW_PORT}" ./scripts/start.sh &
CANARY_PID=$!

until curl -sf "http://localhost:${NEW_PORT}/health" >/dev/null; do
  sleep 1
done
echo "  ✔ New release healthy on port ${NEW_PORT}"

# 4. Atomic symlink switch
ln -sfn "${NEW_RELEASE}" "${SYMLINK}"
echo "  ✔ Symlink switched to new release"

# 5. Stop old app, restart on main port
systemctl restart myapp
kill "${CANARY_PID}" 2>/dev/null || true

# 6. Prune old releases
ls -dt "${RELEASES_DIR}"/*/ \
  | tail -n +$(( KEEP_RELEASES + 1 )) \
  | xargs -r rm -rf
echo "  ✔ Old releases pruned (kept ${KEEP_RELEASES})"
BASH
#!/usr/bin/env bash
# =============================================================
# script_template.sh — Production bash script template
# Usage: ./script_template.sh [OPTIONS] <required_arg>
# =============================================================

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

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

# ── Defaults ──────────────────────────────────────────────
LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"
VERBOSE=false
DRY_RUN=false

# ── Libraries ─────────────────────────────────────────────
source "${SCRIPT_DIR}/lib/logging.sh"

# ── Cleanup ───────────────────────────────────────────────
_TMPDIR=""
cleanup() {
  local ec=$?
  [[ -d "${_TMPDIR}" ]] && rm -rf "${_TMPDIR}"
  (( ec == 0 )) \
    && log_ok  "Done (${SCRIPT_NAME})" \
    || log_err "Failed with exit code ${ec} (${SCRIPT_NAME})"
}
trap cleanup EXIT
trap 'log_err "Caught ERR at line ${LINENO}: ${BASH_COMMAND}"' ERR

# ── Argument parsing ──────────────────────────────────────
usage() {
  echo "Usage: ${SCRIPT_NAME} [-v] [-n] [-V] <required_arg>"
  echo "  -v  Verbose"
  echo "  -n  Dry run"
  echo "  -V  Version"
}

while getopts ":vnV" opt; do
  case "${opt}" in
    v) VERBOSE=true  ;;
    n) DRY_RUN=true  ;;
    V) echo "v${VERSION}"; exit 0 ;;
    ?) echo "Unknown: -${OPTARG}" >&2; usage; exit 1 ;;
  esac
done
shift $(( OPTIND - 1 ))
[[ $# -eq 0 ]] && { usage; exit 1; }
ARG1="${1}"

# ── Prerequisites ─────────────────────────────────────────
require_cmd mysql curl jq
require_env DB_HOST DB_PASS

# ── Main ──────────────────────────────────────────────────
_TMPDIR=$(mktemp -d)
log_info "Starting ${SCRIPT_NAME} v${VERSION}"

main() {
  log_info "Processing: ${ARG1}"
  if "${DRY_RUN}"; then
    log_warn "DRY RUN — no changes made"
    return 0
  fi
  # ... real work here ...
}

main
bash — blue-green deploy
vriddh@prod-01:~/app$./blue_green_deploy.sh
Deploying to: /opt/myapp/releases/20260501_101402
✔ Files deployed
✔ Migrations complete
Waiting for new release to become healthy...
✔ New release healthy on port 8081
✔ Symlink switched to new release
✔ Old releases pruned (kept 5)
OK Done (blue_green_deploy.sh)
✔ Pattern summary — Write idempotent scripts by always checking before acting. Use the retry() function for any network operation. Apply blue-green deployment for zero-downtime releases using atomic symlink switches. Use the production template as the starting point for every new script — it gives you logging, cleanup, argument parsing, and error handling without re-inventing them each time.