Skip to content
DevOps AI ToolKit
Newsletter
Core Guide · Engineering Fundamentals

Bash Scripting

Write Bash that's safe to run in production — strict mode, proper quoting, error handling with traps, argument parsing, and battle-tested templates for health checks, backups, and deploy scripts you can drop into a pipeline.

Last reviewed August 2026 Tutorial · Templates · 26 min read

Technically validated: Examples target Bash 5.x on Linux. shellcheck-clean. A few constructs (associative arrays, mapfile) require Bash 4+, which excludes the default macOS /bin/bash 3.2 — noted inline.

On this page

Bash is the connective tissue of DevOps: the glue in CI steps, the entrypoint of a container, the cron job that rotates backups. It’s also the language people write most carelessly — and a careless Bash script is how a “cleanup” job deletes the wrong directory. This guide is about writing Bash that’s safe to run unattended in production: strict mode, correct quoting, real error handling, and templates you can adapt without re-learning the footguns.

The production script skeleton

Start from this every time. The rest of the guide explains each line.

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'

# --- config -------------------------------------------------------------------
readonly SCRIPT_NAME="${0##*/}"
LOG_LEVEL="${LOG_LEVEL:-info}"

# --- logging ------------------------------------------------------------------
log() { printf '%s [%s] %s\n' "$(date -u +%FT%TZ)" "$1" "${*:2}" >&2; }
die() { log error "$*"; exit 1; }

# --- cleanup on ANY exit ------------------------------------------------------
cleanup() { rm -rf "${TMPDIR:-}"; }
trap cleanup EXIT
trap 'die "failed at line $LINENO"' ERR

# --- main ---------------------------------------------------------------------
main() {
  TMPDIR="$(mktemp -d)"
  log info "starting $SCRIPT_NAME"
  # ... work ...
}

main "$@"

Strict mode: set -Eeuo pipefail

This single line eliminates whole categories of bugs:

  • -e — exit immediately if any command fails (non-zero exit). No more plowing ahead after a failed cd.
  • -u — error on unset variables. A typo’d $FILNAME becomes a hard error instead of an empty string (which is how rm -rf "$DIR/" becomes rm -rf /).
  • -o pipefail — a pipeline fails if any stage fails, not just the last. Without it, false | true “succeeds.”
  • -E — makes the ERR trap fire inside functions and subshells too.
set -Eeuo pipefail

Quoting: the rule that prevents 90% of bugs

Quote every variable expansion unless you have a specific reason not to: "$var", "${array[@]}", "$(cmd)". Unquoted expansions undergo word-splitting and glob expansion — a filename with a space becomes two arguments, and a value of * expands to every file in the directory.

file="my report.txt"
rm $file        # WRONG: tries to remove 'my' and 'report.txt'
rm "$file"      # RIGHT: removes 'my report.txt'

Setting IFS=$'\n\t' (as in the skeleton) further tames word-splitting by removing the space from the default separators — so iterating over lines behaves predictably.

Variables, parameters, and arguments

name="prod"                       # no spaces around =
readonly MAX_RETRIES=3            # constant
count=$((count + 1))              # arithmetic

greeting="Hello, ${name}!"        # ${} disambiguates in strings
default="${THRESHOLD:-90}"        # use $THRESHOLD, or 90 if unset/empty
required="${TOKEN:?TOKEN is required}"   # exit with a message if unset

Positional parameters carry a script’s arguments:

echo "$0"     # script name
echo "$1 $2"  # first, second argument
echo "$#"     # argument count
echo "$@"     # all args, each a separate quoted word ("$@" — always quote it)

Conditionals

Use [[ ... ]] (Bash’s test) over the older [ ... ] — it’s safer with unquoted variables and supports pattern matching.

if [[ -f "$config" ]]; then           # file exists
  log info "using $config"
elif [[ -z "${config:-}" ]]; then     # variable empty/unset
  die "no config provided"
fi

[[ "$env" == prod* ]] && log warn "production run"   # glob match
[[ "$version" =~ ^v[0-9]+\. ]] && echo "tagged release"  # regex match

Common file tests: -f (file), -d (directory), -e (exists), -r/-w/-x (perms), -s (non-empty); string tests: -z (empty), -n (non-empty), ==, !=.

Loops

# Iterate lines of a file safely (handles spaces, no word-splitting surprises)
while IFS= read -r line; do
  process "$line"
done < input.txt

# Iterate an array
for host in "${hosts[@]}"; do
  ssh "$host" uptime
done

# C-style
for ((i = 0; i < 5; i++)); do echo "$i"; done

Functions

# Return data via stdout; return status via exit code.
get_status() {
  local host="$1"                 # ALWAYS 'local' — avoid clobbering globals
  curl -fsS "https://$host/health" || return 1
}

if status="$(get_status "$1")"; then
  log info "healthy: $status"
else
  die "unhealthy host: $1"
fi

Declare function-scoped variables with local, or they leak into the global scope and cause spooky action at a distance.

Arrays and associative arrays

# Indexed array
services=(web api worker)
services+=(cron)                  # append
echo "${#services[@]}"            # length
for s in "${services[@]}"; do :; done

# Associative array (Bash 4+; NOT the macOS default /bin/bash 3.2)
declare -A port
port[web]=8080
port[api]=3000
for name in "${!port[@]}"; do
  echo "$name -> ${port[$name]}"
done

Error handling with traps

Traps run code on signals and on exit — the key to scripts that clean up after themselves even when they fail.

cleanup() {
  local rc=$?
  rm -rf "${WORKDIR:-}"
  log info "cleaned up (exit $rc)"
}
trap cleanup EXIT               # runs on ANY exit: success, failure, or Ctrl-C
trap 'die "error on line $LINENO"' ERR
trap 'die "interrupted"' INT TERM

The EXIT trap is the single most valuable habit for safe scripts: temp files, lock files, and partial state get cleaned up whether the script succeeds, errors under set -e, or is killed.

Redirects and file descriptors

cmd > out.log 2> err.log         # stdout and stderr to separate files
cmd > combined.log 2>&1          # both to one file (order matters!)
cmd &> combined.log              # Bash shorthand for the above
cmd < input.txt                  # stdin from a file
log() { echo "$*" >&2; }         # logs to stderr, keeping stdout for data
exec 3>/tmp/audit.log            # open FD 3; write with >&3

Sending logs to stderr and data to stdout is the convention that lets your script be composed in a pipeline without log lines polluting the data stream.

Working with JSON and APIs (jq)

Modern automation talks to APIs. jq parses JSON safely — never grep JSON.

# Fetch and extract a field
version="$(curl -fsS https://api.example.com/status | jq -r '.version')"

# Check a health endpoint, fail the script if not "ok"
status="$(curl -fsS "$URL/health" | jq -r '.status')"
[[ "$status" == "ok" ]] || die "unhealthy: $status"

# Iterate JSON array elements safely
curl -fsS "$URL/nodes" | jq -r '.[] | .name' | while IFS= read -r node; do
  echo "checking $node"
done

Real automation templates

Health check with retries

check() {
  local url="$1" tries="${2:-3}"
  for ((i = 1; i <= tries; i++)); do
    if curl -fsS -o /dev/null --max-time 5 "$url"; then
      log info "healthy: $url"; return 0
    fi
    log warn "attempt $i/$tries failed for $url"; sleep $((i * 2))
  done
  return 1
}
check "https://api.example.com/health" 5 || die "health check failed"

Safe backup with rotation

backup() {
  local src="$1" dest="$2" keep="${3:-7}"
  [[ -d "$src" ]] || die "source not found: $src"
  mkdir -p "$dest"
  local file="$dest/backup-$(date -u +%F-%H%M%S).tar.gz"
  tar czf "$file" -C "$src" . || die "backup failed"
  log info "wrote $file"
  # Keep only the newest $keep backups; delete the rest.
  ls -1t "$dest"/backup-*.tar.gz | tail -n "+$((keep + 1))" | while IFS= read -r old; do
    rm -f -- "$old"; log info "pruned $old"
  done
}

Disk-usage alert

threshold="${DISK_THRESHOLD:-90}"
df -P | awk 'NR>1 {gsub(/%/,"",$5); print $5, $6}' | while read -r used mount; do
  (( used >= threshold )) && log warn "disk ${used}% on ${mount}"
done

Debugging

bash -n script.sh                # syntax check without running
bash -x script.sh                # trace: print each command as it runs
set -x; risky_section; set +x    # trace just one part
PS4='+ ${BASH_SOURCE}:${LINENO}: '   # richer trace prefix (file:line)

Run shellcheck on every script — it’s the closest thing Bash has to a compiler. Most CI setups can run it as a lint step; it catches quoting bugs, unset-variable risks, and dozens of subtle mistakes before they reach production.

Security

  • Validate all input before using it in a command — especially anything that becomes part of a path or a shell command.
  • Never eval untrusted input, and avoid building commands by string concatenation.
  • Use mktemp for temp files (predictable names in /tmp are a symlink-attack vector).
  • Quote everything, particularly before rm, find -delete, and redirects into files.

Production checklist

  • #!/usr/bin/env bash + set -Eeuo pipefail + IFS=$'\n\t' at the top.
  • Every variable expansion quoted; every function variable local.
  • An EXIT trap that cleans up temp files/locks; ERR trap that reports the line.
  • curl -fsS (fail on HTTP errors) and jq -r for anything talking to an API.
  • shellcheck passes with no warnings, in CI.
  • Secrets from env/files, never args or the script; tracing off around secret handling.
  • Destructive operations dry-run-able or guarded (--force flag, confirmation, or a -n mode).
  • Idempotent where it runs on a schedule — safe to run twice.

Frequently asked questions

What does set -euo pipefail actually do? -e exits on any command failure, -u errors on unset variables, -o pipefail fails a pipeline if any stage fails. Together they turn silent failures into loud ones. Add -E so the ERR trap also fires inside functions.

Why do people say to always quote variables? Unquoted $var is word-split on whitespace and glob-expanded. A filename with a space becomes two arguments; a value of * expands to every file. "$var" prevents both — the single highest-value habit in Bash.

[[ vs [ — which should I use? [[ ... ]] in Bash. It’s safer with empty/unset variables, doesn’t word-split, and supports == globbing and =~ regex. [ ... ] (the POSIX test) is for portability to non-Bash shells.

How do I clean up temp files even if the script fails? trap cleanup EXIT. The EXIT trap runs on success, on set -e failure, and on Ctrl-C, so it’s the reliable place to rm temp dirs and release locks.

Why does my script work on Linux but fail on macOS? macOS ships Bash 3.2, so associative arrays, mapfile, and ${var^^} fail there. Use #!/usr/bin/env bash with a newer Bash (Homebrew), or stick to Bash 3.2-compatible constructs.

How should I handle secrets in a Bash script? From environment variables or a 0600 file — never hard-coded and never as command-line arguments (visible in ps). Disable set -x tracing around any secret handling.

Continue learning

Related Core Guides that build on this one.

Written by James Joyner IV, Sr. Systems Software Engineer — for engineers who run what they build.

Last reviewed August 2026. Found an error or an out-of-date command? Tell us — accuracy is the point of a Core Guide.