Bash Scripting: Practical Patterns for Systems Work
Production-grade Bash scripting patterns: structure, error handling, argument parsing, logging, locking, and a full deployment script template.
Bash is the glue of Linux systems work. I have written scripts ranging from one-liners to thousand-line deployment frameworks, and the patterns below represent hard-won lessons about what holds up at 3 AM when something breaks. Bash is not a general-purpose language — use Python for anything that needs data structures, HTTP calls, or complex logic — but for process orchestration, system automation, and operations tooling, a well-structured Bash script beats almost anything for portability and transparency.
Every non-trivial script should open with this header. The few extra seconds it takes are worth avoiding hours of debugging half-completed state.
#!/usr/bin/env bash# ============================================================# Script: deploy-app.sh# Purpose: Deploy application artifacts to target host# Author: Valeriy Khashkovskiy# Usage: ./deploy-app.sh [-e ENV] [-v VERSION] [-h]# ============================================================set -euo pipefail# -e exit immediately on any non-zero command# -u treat unset variables as errors# -o pipefail pipeline fails if any command in it failsIFS=$'\n\t'# Safer default field separator — avoids word-splitting on spaces
Why each flag matters (with examples)
set -e — Without this, scripts happily continue after a failed cp or curl. With it, they stop and let you investigate.set -u — Catches typos in variable names. rm -rf $TMPDI/ (note the typo) would become rm -rf / without this flag.set -o pipefail — Without this, cat missing_file.txt | grep foo returns exit code 0 because grep succeeded — even though cat failed.IFS=$'\n\t' — The default IFS includes space, which causes word-splitting on filenames with spaces. Changing it prevents most quoting bugs.
# Iterate over a listfor env in dev staging production; do echo "Deploying to ${env}..."done# Iterate over arrayfor server in "${SERVERS[@]}"; do ssh "${server}" "sudo systemctl restart app"done# C-style numeric loopfor (( i=1; i<=10; i++ )); do echo "Attempt ${i}"done# Iterate over filesfor conf in /etc/app/*.conf; do [[ -f "${conf}" ]] || continue # skip if glob matched nothing echo "Processing ${conf}"done# Iterate over command output lineswhile IFS= read -r line; do echo "Processing: ${line}"done < <(find /var/data -name "*.json" -mtime -1)
#!/usr/bin/env bash# ============================================================# deploy-app.sh — Application deployment script# ============================================================set -euo pipefailIFS=$'\n\t'# ── Constants ────────────────────────────────────────────────readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"readonly SCRIPT_NAME="$(basename "$0")"readonly LOG_FILE="/var/log/app/deploy-$(date +%Y%m%d-%H%M%S).log"readonly LOCKFILE="/var/run/${SCRIPT_NAME%.sh}.lock"readonly DEPLOY_USER="app"readonly DEPLOY_BASE="/opt/app"# ── Logging ──────────────────────────────────────────────────mkdir -p "$(dirname "${LOG_FILE}")"log() { local level="${1}"; shift local ts; ts=$(date '+%Y-%m-%d %H:%M:%S') local line="[${ts}] [${level}] $*" echo "${line}" >> "${LOG_FILE}" case "${level}" in ERROR|WARN) echo "${line}" >&2 ;; *) echo "${line}" ;; esac}die() { log "ERROR" "$1"; exit "${2:-1}"; }# ── Cleanup / Locking ────────────────────────────────────────cleanup() { rm -rf "${LOCKFILE}" log "INFO" "Cleanup done. Full log: ${LOG_FILE}"}trap cleanup EXITtrap 'die "Interrupted" 130' INT TERMif ! mkdir "${LOCKFILE}" 2>/dev/null; then die "Another deployment is in progress. Aborting."fiecho $$ > "${LOCKFILE}/pid"# ── Usage ────────────────────────────────────────────────────usage() { cat <<EOFUsage: ${SCRIPT_NAME} -e ENV -v VERSION [-n] [-h] -e ENV Environment: dev | staging | prod -v VERSION Version tag to deploy (e.g. 2.4.1) -n Dry-run (no changes made) -h HelpEOF}# ── Argument Parsing ─────────────────────────────────────────ENVIRONMENT=""VERSION=""DRY_RUN=falsewhile [[ $# -gt 0 ]]; do case "$1" in -e) ENVIRONMENT="$2"; shift 2 ;; -v) VERSION="$2"; shift 2 ;; -n) DRY_RUN=true; shift ;; -h) usage; exit 0 ;; *) usage >&2; die "Unknown option: $1" 2 ;; esacdone[[ -n "${ENVIRONMENT}" ]] || { usage >&2; die "-e ENV is required" 2; }[[ -n "${VERSION}" ]] || { usage >&2; die "-v VERSION is required" 2; }# ── Pre-flight Checks ────────────────────────────────────────log "INFO" "=== Deployment starting: ${SCRIPT_NAME} v${VERSION} → ${ENVIRONMENT} ==="log "INFO" "Dry-run: ${DRY_RUN}"for cmd in rsync ssh systemctl; do command -v "${cmd}" &>/dev/null || die "Required command not found: ${cmd}" 127donecase "${ENVIRONMENT}" in prod|production) SERVERS=("web01.prod" "web02.prod") ;; staging) SERVERS=("web01.staging") ;; dev|development) SERVERS=("web01.dev") ;; *) die "Unknown environment: ${ENVIRONMENT}" 2 ;;esac# ── Deploy Function ──────────────────────────────────────────deploy_to_server() { local server="${1}" log "INFO" "Deploying to ${server}..." if [[ "${DRY_RUN}" == "true" ]]; then log "INFO" "[DRY-RUN] Would rsync to ${server}" return 0 fi rsync -az --delete \ "/opt/releases/${VERSION}/" \ "${DEPLOY_USER}@${server}:${DEPLOY_BASE}/current/" \ || die "rsync to ${server} failed" 1 ssh "${DEPLOY_USER}@${server}" \ "sudo systemctl restart app && systemctl is-active --quiet app" \ || die "Service restart failed on ${server}" 1 log "INFO" "Successfully deployed to ${server}"}# ── Main ─────────────────────────────────────────────────────for server in "${SERVERS[@]}"; do deploy_to_server "${server}"donelog "INFO" "=== Deployment complete: ${VERSION} → ${ENVIRONMENT} ==="exit 0
3
Test with dry-run first
./deploy-app.sh -e staging -v 2.4.1 -n
4
Run for real
./deploy-app.sh -e staging -v 2.4.1
The readonly SCRIPT_DIR pattern using BASH_SOURCE[0] is the correct way to get the script’s own directory even when the script is sourced or called via a symlink. Never use $0 alone for this purpose.