Shell Strictness (set -euo pipefail)
the deploy that "succeeded"
Developer toolingdeveloper tooling / shell
Friday, 16:40
CI is green. Nobody deployed anything.
deploy.sh:
#!/bin/bash
VERSION=$(git describe --tags)
cd /srv/app
docker build -t app:$VERSION .
docker push app:$VERSION
kubectl set image deploy/app app=app:$VERSION
Someone renamed /srv/app last week. The cd printed "No such file or directory" to stderr... and bash moved to the next line anyway. It built an image from the CI checkout root, pushed it, and exited 0. Green check, wrong artifact in prod.
The concept
Bash's default posture is "keep going." A failing command is just a number nobody read. Three switches flip that default:
- set -e — abort the script when any command returns non-zero
- set -u — abort on an unset variable (without it, a typo like $VERSOIN is silently "" — that's how
rm -rf "$DIR"/*becomesrm -rf /*) - set -o pipefail — a pipeline normally reports only the LAST command's status
pipefail is the one people skip. Concretely:
curl -sf https://api.internal/user | jq -r .id
Endpoint 404s → curl exits 22, prints nothing → jq reads empty input, exits 0 → pipeline exit code: 0. Your script happily continues with an empty id. Add pipefail and the same line exits 22.
Where -e lies to you (the intermediate part)
1️⃣ Any command inside if, while, &&, ||, or ! is exempt — and so is everything nested inside it. A function called as if check_health; then runs with -e effectively OFF for its whole body.
2️⃣ local x=$(cmd) swallows failures. local is itself a command, and ITS exit status (0) is what bash sees. Same for export x=$(cmd). Split it: local x; x=$(cmd).
3️⃣ cmd || true is a deliberate mute — fine, but grep for it when debugging a script that "can't fail."
4️⃣ Plain -e does not propagate an ERR trap into functions, subshells, or command substitutions. You need -E.
The header worth memorizing
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'echo "FAILED line $LINENO: $BASH_COMMAND" >&2' ERR
That trap turns "exit 1, no idea why" into an exact line and command — worth more than the flags on their own.
Two gotchas
- This is NOT POSIX sh. On Debian/Alpine
#!/bin/shis dash/busybox, which has nopipefail— your script dies at line 2 with "Illegal option". Use#!/usr/bin/env bash. - Docker RUN lines are separate shells.
RUN set -eon one line does nothing for the next. Set it per-RUN, or useSHELL ["/bin/bash", "-euo", "pipefail", "-c"]once near the top of the Dockerfile.
Your exercise
Predict the output and final exit code, then run it:
#!/usr/bin/env bash
set -euo pipefail
f() { local x=$(exit 3); echo "inside: $?"; }
f
echo "survived: $?"
Did the script stop? Now make it actually exit 3 by changing exactly one line. Bonus: add set -o pipefail to the longest pipeline in a script you own and see whether it still passes.