Write Bash Scripts That Fail Loudly
07 Apr 2026A bash script that fails silently is worse than one that doesn’t run at all. It tells you the deploy succeeded while it actually stopped halfway through. Here’s what stops that.
Start every script with this line
#!/usr/bin/env bash
set -euo pipefail
Three flags, three different failure modes closed:
-e— exit immediately if any command fails. Without it, a failedcdorrmjust gets logged and the script keeps going with the wrong assumptions.-u— treat unset variables as an error. Without it, a typo like$DEPLOY_ENVinstead of$DEPLOY_ENVIRONMENTsilently expands to an empty string instead of failing loud.-o pipefail— a pipeline fails if any command in it fails, not just the last one. Without it,curl bad-url | jq .fieldreports success becausejqon empty input exits 0.
The trap -e doesn’t catch
set -e does not fail on commands inside an if, a &&/|| chain, or a command substitution used as a condition. This is intentional — but it means:
if grep -q "error" logfile.txt; then
echo "found errors"
fi
…won’t abort the script if grep fails to find the file at all. That’s usually fine. What’s not fine is assuming -e protects a conditional the same way it protects a standalone command. Test the actual failure path, not just the happy path.
Check what you actually depend on, before you use it
command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 1; }
Do this at the top of the script for every binary you shell out to. A missing dependency should fail in the first second with a clear message, not three steps in with command not found.
Quote your variables
rm -rf "$BUILD_DIR"/*
Not rm -rf $BUILD_DIR/*. If $BUILD_DIR is ever empty — unset variable, failed earlier step that should have aborted but didn’t — the unquoted version expands to rm -rf /*. This single habit prevents the worst class of bash accidents. Quote every variable expansion unless you have a specific reason not to (word-splitting an array, which is rare).
Trap on exit for cleanup
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
trap ... EXIT runs on any exit path — success, set -e failure, or Ctrl-C. Without it, a script that dies partway through leaves temp files and lock files behind, and the next run trips over them.
Log what failed, not just that something failed
run_step() {
echo "-> $*"
if ! "$@"; then
echo "FAILED: $*" >&2
exit 1
fi
}
run_step docker build -t myapp .
run_step docker push myapp
When set -e kills a script, you get the shell’s default error — often nothing more than a nonzero exit code in your CI log. Wrapping key steps like this tells you which one broke without needing to reproduce it locally.
Sanity-check inputs before you act on them
[ -z "${1:-}" ] && { echo "usage: $0 <environment>" >&2; exit 1; }
[[ "$1" =~ ^(staging|production)$ ]] || { echo "invalid environment: $1" >&2; exit 1; }
A deploy script that accepts any string for “environment” will eventually get a typo and deploy the wrong target. Validate against an allowlist, not just “is it non-empty.”
Test the failure path on purpose
Before you trust a script in CI, break something deliberately — point it at a bad URL, an unset variable, a missing binary — and confirm it stops with a message you’d understand at 3am. A script that “works” only ever tested on the happy path isn’t tested.