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.
1
Idempotent scripts — safe to run multiple times
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
2
Retry with exponential backoff
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
}
3
Blue-green deployment pattern
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})"
4
The complete production script template
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
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.