On this page
- The production script skeleton
- Strict mode: set -Eeuo pipefail
- Quoting: the rule that prevents 90% of bugs
- Variables, parameters, and arguments
- Conditionals
- Loops
- Functions
- Arrays and associative arrays
- Error handling with traps
- Redirects and file descriptors
- Working with JSON and APIs (jq)
- Real automation templates
- Debugging
- Security
- Production checklist
- Frequently asked questions
- Related resources
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 failedcd.-u— error on unset variables. A typo’d$FILNAMEbecomes a hard error instead of an empty string (which is howrm -rf "$DIR/"becomesrm -rf /).-o pipefail— a pipeline fails if any stage fails, not just the last. Without it,false | true“succeeds.”-E— makes theERRtrap 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
evaluntrusted input, and avoid building commands by string concatenation. - Use
mktempfor temp files (predictable names in/tmpare 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
EXITtrap that cleans up temp files/locks;ERRtrap that reports the line. curl -fsS(fail on HTTP errors) andjq -rfor 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 (
--forceflag, confirmation, or a-nmode). - 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.
Related resources
- Guide: Linux Commands — the commands your scripts orchestrate.
- Guide: Git Commands and DevOps Practices for the workflows scripts plug into.
- Prompts: DevOps AI prompts for generating and reviewing shell automation safely.
- Error library: specific Bash errors like ambiguous redirect and bad substitution.
Continue learning
Related Core Guides that build on this one.
- Linux CommandsA searchable Linux command reference for engineers — files, text, storage, processes, networking, services and troubleshooting, with Ubuntu-first examples.
- Git CommandsEvery Git command a DevOps engineer needs, organized by workflow — with copyable examples and clear risk labels for the destructive ones.
- DevOps PracticesThe practices that define modern delivery — IaC, CI/CD, GitOps, observability, SRE, progressive delivery — with when to use each, and when not to.
- DevOps ToolsA working engineer’s map of the DevOps toolchain — source control to platform engineering — with what each tool is for, its trade-offs, and how to choose.