Three silent failures of a bare script
Bash defaults are the root of debugging hell: commands do not stop the script (-e off), undefined variables act as empty strings (-u off), and a pipe only reports the last command’s status (pipefail off).
The result is a script that "looks finished" but broke halfway: cd fails yet rm still runs in the wrong directory, a typo’d variable silently becomes empty, grep | head hides an upstream crash. Production incidents hide in exactly these lines.
cd /data/release && rm -rf old/ # If cd fails, rm should never run
cp app.conf /etc/app.conf # On failure the script just continues
echo "deploy done" # Illusion: everything is fineFirst line of defense: set -euo pipefail
Put set -euo pipefail right after the shebang: -e exits on any failing command, -u errors on undefined variables instead of treating them as empty, -o pipefail makes a pipe adopt the first failure’s exit code.
One caveat about -e: failures inside conditional contexts (if/while/&&/||) do not trigger exit — that is a feature, and patterns like cmd || exit 1 still need explicit handling.
#!/usr/bin/env bash
set -euo pipefail
# Add during debugging to echo each executed command
# set -xClean up with trap
When a script dies midway or gets killed by Ctrl+C, temp files, child processes and lock files are left behind. Hook cleanup onto EXIT with trap: it runs on normal exit, on error, and on interruption alike.
Create temp dirs with mktemp -d (never hardcode paths — avoids concurrency clashes and symlink attacks), and have the cleanup function remove only what it created.
tmpdir=$(mktemp -d)
cleanup() { rm -rf "$tmpdir"; }
trap cleanup EXIT
# Add this too to run cleanup on Ctrl+C / kill:
# trap cleanup INT TERMQuoting discipline: double-quote every expansion
An unquoted variable splits on spaces and vanishes when empty: rm -rf $dir with an empty dir becomes a bare rm -rf (disaster-grade). The discipline is simple — always write "$var", never $var.
Three common companions: use -- or a ./ prefix when filenames may start with -; use ${1:?message} to fail fast on required args; use ${2:-default} for optional ones.
rm -rf -- "$dir" # -- prevents filenames from being read as options
src="${1:?usage: $0 <source-dir>}" # Error out when an argument is missing
out="${2:-./dist}" # Provide a default valueValidate inputs, stay idempotent
Validate external inputs (positional args, env vars, find output) before use: check commands with command -v, paths with [ -d ]/[ -f ]. Fail at startup rather than halfway through.
Make scripts idempotent: mkdir -p over mkdir, cp -f/rsync over bare cp. A half-failed run can then simply be re-run without manual cleanup.
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
[ -d "$src" ] || { echo "directory not found: $src" >&2; exit 1; }
mkdir -p "$out" # Idempotent: no error if it already existsDebugging and the pre-production checklist
Three tools cover ninety percent of issues: bash -n checks syntax only, bash -x traces every line, and shellcheck statically catches nearly all of the pitfalls above, including unquoted variables. Wire shellcheck into CI and script-quality problems mostly vanish.
Final checklist: shebang + set -euo pipefail + trap cleanup + double-quoted expansions + input validation + idempotent operations + shellcheck passing. Copy this skeleton and your scripts go from "good luck" to "trustworthy".
bash -n deploy.sh # Syntax check only
bash -x deploy.sh # Echo each line as it runs (debug)
shellcheck deploy.sh # Static analysis (recommended in CI)