Why Your Deploy Script Lied to You: Bash Strict Mode as an Engineering Decision
Bash's 'strict mode' (set -euo pipefail) turns silent failures into loud ones — especially pipefail, which stops 'build.sh | tee log' from masking a broken build. Here's what each option does, a worked deploy script, and the honest trade-offs.
18 Apr 2026, 12:50 UTC

The pipeline that reported success while everything burned
Here's a scenario that has bitten nearly everyone who writes shell automation: a build script runs ./build.sh | tee build.log, the build fails halfway through, but the script cheerfully continues and deploys a broken artifact. Nothing is wrong with tee. The problem is Bash's default rule for pipelines: the exit status of a pipeline is the exit status of the last command only. tee succeeded, so the pipeline "succeeded."
The fix is one line at the top of the script — set -euo pipefail, often called Bash "strict mode" — but the interesting part isn't the syntax. It's deciding where strict mode earns its keep and where it creates new problems. This post makes the case for adopting it deliberately, not dogmatically.
What each of the three options actually does
Strict mode isn't a feature; it's three independent switches bundled by convention.
set -e(errexit): the script exits immediately when a command returns a non-zero status, instead of plowing ahead with a half-completed state.set -u(nounset): expanding an unset variable becomes an error instead of silently evaluating to an empty string. This catches typos like$APP_DRIthat would otherwise delete or write to the wrong path.set -o pipefail: a pipeline's exit status reflects the rightmost command that failed, not just the final command. This is the least-known option and arguably the most valuable in CI and deployment scripts, which are full of pipes intotee,grep, andwc.
All three are long-standing Bash features — pipefail arrived in Bash 3.0 — so they work on essentially any Bash you'll encounter. They are not POSIX sh features, so don't rely on them under #!/bin/sh (on Debian/Ubuntu that's dash, which will reject pipefail outright).
A worked example: the deploy script
Consider a minimal deploy script:
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/myapp}"
cd "$APP_DIR"
git pull --ff-only
./build.sh | tee build.log
./restart.sh
Run this on any Linux box with Bash; it needs no special permissions beyond what the deploy itself requires. Three things are happening because of the first two lines:
- If
git pullfails (merge conflict, network down),set -estops the script before a broken tree gets built and restarted. ${APP_DIR:-/srv/myapp}is the explicit-default syntaxset -upushes you toward: ifAPP_DIRis unset in the environment, you get a sane fallback instead of an error — or, without the:-default, a loud failure instead of a silentcd "".- If
build.shfails,pipefailmakes the pipeline fail even thoughteeexits 0, andset -ethen aborts beforerestart.shruns. Withoutpipefail, this exact line is the classic masked-failure bug.
You can verify the pipefail behavior yourself in any Bash shell — no files, no privileges needed:
bash -c 'set -o pipefail; false | true; echo "with pipefail: $?"'
bash -c 'false | true; echo "without pipefail: $?"'
The first prints 1, the second prints 0. Similarly, bash -c 'set -u; echo "$never_defined"' aborts with an "unbound variable" error. If your system behaves differently, check help set and your Bash version (bash --version) — semantics have edge-case differences across versions, and macOS still ships the ancient Bash 3.2 as /bin/bash.
The trade-off: strict mode is a bug-detector, and that's expensive
Here's the honest limitation. set -e has a famously long list of exceptions: it does not trigger inside if/while conditions, on either side of && or ||, or (without pipefail and even then, only for the pipeline as a whole) mid-pipeline. A failing command inside $(...) command substitution behaves differently depending on context and Bash version. So strict mode is not a substitute for explicit error handling — it's a safety net with holes you should know about.
The bigger practical cost: enabling strict mode on a legacy script is a breaking change. Scripts that unknowingly relied on silent empty variables or ignored failures will now abort, sometimes in surprising places. Sourcing third-party snippets under set -u is a common casualty, because plenty of library code references variables it never set.
The pragmatic policy most teams land on:
- New scripts: strict mode from line one, non-negotiable. It's far easier to write strict-clean code than to retrofit it.
- Legacy scripts: migrate incrementally. Add
set -o pipefailfirst (fewest surprises), thenset -u, thenset -e, testing each step on a copy in staging before it touches production automation. - Anywhere failure really matters: pair strict mode with a
trap ... ERRhandler or explicitif ! cmd; thenchecks for the casesset -ecan't see.
The takeaway
Strict mode is cheap insurance with a known deductible. One line — set -euo pipefail — converts an entire class of silent failures into loud, immediate ones, and pipefail alone justifies it in any script that pipes through tee. Verify the behavior on your own system with the two one-liners above, adopt it for everything new, and treat retrofitting old scripts as the code change it genuinely is: tested, staged, and rolled out on purpose.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.