Robust scripts
set -euo pipefail turns bash into a fail-fast interpreter: -e exits on any error, -u treats unset variables as errors, -o pipefail catches failures inside pipelines. trap cleans up on exit. Functions with local variables prevent variable leakage.
A bash script without set -euo pipefail is a script that silently continues past failures. A command that returns exit code 1 is ignored. A typo in a variable name expands to an empty string. A failed command inside a pipeline is masked by the last command’s success. The script prints “Done!” and leaves a half-finished, corrupt, or overwritten state behind. Three flags and one trap call flip this entirely: bash becomes a strict, fail-fast interpreter that stops at the first sign of trouble and runs your cleanup code before it exits. These are not optional polish — they are the difference between a script you can trust in production and one you babysit.
After this lesson you can write a correct shebang line, apply set -euo pipefail and explain what each flag does, register cleanup with trap, write functions that use local to avoid variable leakage, and know the three common set -e gotchas.
#!/usr/bin/env bash is the correct shebang for portable bash scripts. It asks the OS to run whichever bash appears first in $PATH, rather than hardcoding /bin/bash (which does not exist on some systems, notably NixOS and some containers).
#!/usr/bin/env bash
# Always the first line — no blank lines above it/bin/sh invokes the system POSIX shell, which may be dash, ash, or busybox — not bash. If your script uses [[ ]], arrays, or any bash-specific feature, it must use #!/usr/bin/env bash. Running a bash script under sh produces cryptic parse errors for constructs that are valid bash but not POSIX sh.
set -e exits the script immediately if any command returns a non-zero exit code. Without it, every failed command is silently ignored and execution continues.
set -e
cp /nonexistent /tmp/ # exits here — cp fails
echo "This never runs" # skippedset -u treats any reference to an unset variable as an error and exits.
set -u
echo "$UNDEFINED_VAR" # exits: bash: UNDEFINED_VAR: unbound variableThis catches the typo-variable bug: $TEMP_DIR vs $TMPDIR — one is set, one is not, and without -u the unset one silently expands to an empty string, causing rm -rf "" or cd "" to do something unexpected.
set -o pipefail makes a pipeline fail if any stage fails, not just the last one.
set -o pipefail
grep "pattern" missing_file.txt | wc -l
# Without pipefail: exits 0 (wc succeeded)
# With pipefail: exits non-zero (grep failed)Put all three together at the top of every script:
set -euo pipefailtrap 'cleanup_cmd' SIGNAL registers a command to run when the script receives a signal or exits. The most useful signal is EXIT — it fires on any exit, including errors.
#!/usr/bin/env bash
set -euo pipefail
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT # always cleaned up, even on error
# ... script body uses $TMPDIR ...
cp important_data.txt "$TMPDIR/"
process "$TMPDIR/important_data.txt"Without the trap, a failure midway through leaves $TMPDIR on disk. With it, bash runs rm -rf "$TMPDIR" before the process exits regardless of how it exits — normal completion, exit 1, or an uncaught error.
Multiple signals: trap 'cleanup' EXIT INT TERM also handles Ctrl-C (INT) and kill (TERM).
Functions encapsulate reusable logic. local prevents variables from leaking into the caller’s scope.
#!/usr/bin/env bash
set -euo pipefail
log() {
local level="$1"
local msg="$2"
echo "[$(date +%T)] [$level] $msg" >&2
}
backup_file() {
local src="$1"
local dest="${src}.bak.$(date +%s)"
cp "$src" "$dest"
log INFO "Backed up $src to $dest"
}
backup_file /etc/nginx/nginx.confWithout local, level, msg, src, dest are global variables. If a calling function also uses a variable named dest, the inner function overwrites it. local scopes the variable to the function’s stack frame. This is not optional style — it prevents action-at-a-distance bugs in scripts longer than a few functions.
Functions return exit codes like commands do. return 0 is success; return N is failure. The last command’s exit code is the function’s exit code if you don’t call return explicitly.
Three common set -e gotchas that trip up experienced engineers.
Gotcha 1: commands in if conditions are exempt. set -e does not fire when a command is used directly as a condition.
set -e
if grep -q "pattern" file.txt; then echo "found"; fi # grep exit 1 = not found, no abort
grep -q "pattern" file.txt # this WILL abort on exit 1Gotcha 2: the last command in a pipeline determines pipefail. With pipefail, the pipeline’s exit code is the rightmost non-zero exit code. If multiple stages fail, only the last non-zero is reported.
Gotcha 3: local itself is a command with an exit code. This is a subtle trap:
set -e
get_value() {
local val=$(some_command_that_fails) # WRONG: local returns 0 even if $() fails
local val
val=$(some_command_that_fails) # CORRECT: assignment separate from declaration
}local val=$(cmd) evaluates cmd, assigns to val, then local runs and returns 0 regardless — swallowing the failure. Declare local first, then assign on a separate line.
A production-grade deploy script skeleton.
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_DIR="/var/app"
RELEASE_DIR="${DEPLOY_DIR}/releases/$(date +%Y%m%d_%H%M%S)"
CURRENT_LINK="${DEPLOY_DIR}/current"
log() { echo "[$(date +%T)] $*" >&2; }
die() { log "ERROR: $*"; exit 1; }
cleanup() {
local code=$?
if [[ $code -ne 0 ]]; then
log "Deploy failed (code $code), rolling back"
[[ -L "$CURRENT_LINK" ]] && log "Current symlink intact"
rm -rf "$RELEASE_DIR" 2>/dev/null || true
fi
}
trap cleanup EXIT
[[ $# -eq 1 ]] || die "Usage: $0 <tarball>"
[[ -f "$1" ]] || die "File not found: $1"
mkdir -p "$RELEASE_DIR"
tar -xzf "$1" -C "$RELEASE_DIR"
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
log "Deploy complete: $RELEASE_DIR"Every variable is local inside functions. trap cleanup EXIT runs on any failure. set -euo pipefail ensures tar or ln failure aborts immediately rather than creating a half-deployed release. die() centralises error reporting.
▸Why this works
On macOS bash 3.2, set -e, set -u, and set -o pipefail all work. The macOS gotcha is local val=$(cmd) — same bug, same fix as on Linux. One additional macOS note: mktemp -d on macOS requires no template argument (it works with just mktemp -d), whereas GNU mktemp also accepts a template. Both forms mktemp -d and mktemp -d /tmp/prefix.XXXXXX work on Linux; only mktemp -d works portably on both.
▸Common mistake
A dangerous pattern: set -e combined with cd followed by rm -rf. If cd "$TARGET_DIR" fails (wrong path, permissions) and set -e is absent, the next rm -rf * runs in whatever directory the script is currently in — potentially deleting the wrong tree. With set -e, the script exits at the failed cd. Belt-and-suspenders form: cd "$TARGET_DIR" || { echo "cd failed" >&2; exit 1; } — explicit even with set -e, because it makes the intent obvious to the next reader.
In a script with set -euo pipefail, you write: local result=$(failing_command). What happens?
#!/usr/bin/env bash picks the correct bash from $PATH instead of hardcoding /bin/bash. set -e exits on any non-zero exit code; set -u exits on unset variable reference; set -o pipefail makes pipeline failures visible — use all three, always. trap 'cleanup' EXIT guarantees cleanup code runs regardless of how the script exits. local scopes function variables to prevent cross-function contamination. The local val=$(cmd) trap: declare local and assign separately so set -e can catch the command’s failure. These four practices transform bash from a fragile sequence of commands into a reliable, auditable automation tool.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.