IAnswerable

Write Bash Scripts That Fail Loudly

07 Apr 2026

A 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:

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.