Who this is for
You write scripts that other things run: CI jobs, cron entries, container entrypoints, deploy scripts. The difference from scripting at a prompt is that nobody is watching the output.
The header
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
#!/usr/bin/env bash rather than #!/bin/bash, because on some systems bash is
not in /bin, and rather than #!/bin/sh, because sh is not bash and most of
what follows is not POSIX.
Then the three options, which are the whole point:
| Option | Without it |
|---|---|
-e | A failing command is ignored and the script continues |
-u | A typo'd variable expands to empty string, silently |
-o pipefail | A pipeline reports the last command's status, so false | true succeeds |
-u is the one people leave out and then lose an afternoon to. rm -rf "$BUILD_DIR"/* with BUILD_DIR unset is rm -rf /*, which GNU rm's default --preserve-root does not catch — with -u it is an error
on the line that referenced it.
Where set -e does not help
This is the part worth knowing, because set -e creates confidence that is
only mostly earned. It does not trigger when:
# ...the command is a condition. This is by design and usually what you want.
if grep -q pattern file; then ...; fi
# ...the command is on the left of && or ||
failing_command || echo "handled"
# ...the failure is anywhere but the end of a pipeline, unless pipefail is set
false | true # exit status 0 without pipefail
# ...it is inside a function called in a condition
if my_function; then ... # errexit is suspended inside my_function
So for anything that genuinely must not continue, check explicitly:
if ! command -v kubectl >/dev/null 2>&1; then
echo "kubectl is required but not installed" >&2
exit 1
fi
And for required configuration, let the shell do it:
: "${DATABASE_URL:?DATABASE_URL must be set}"
: "${ENVIRONMENT:=staging}" # default if unset
The :? form exits with your message, naming the variable, which is a great
deal more useful than a connection error forty lines later.
Cleaning up, whatever happens
workdir="$(mktemp -d)"
cleanup() {
local status=$?
rm -rf "$workdir"
exit "$status"
}
trap cleanup EXIT
trap ... EXIT runs on normal exit, on exit 1, and on an error under set -e. Capturing $? first and re-exiting with it is belt and braces rather than a
necessity: once an EXIT trap finishes, bash exits with the status it was already
exiting with, so a successful rm in the handler cannot turn a failure into success.
What would is an explicit exit 0 inside the handler.
That five-line pattern replaces every rm -rf /tmp/something scattered through
a script and the ones that get skipped when it exits early.
Quoting, which is most of the remaining bugs
# Wrong. Breaks on any path containing a space.
cp $src $dst
# Right.
cp "$src" "$dst"
# Wrong. Re-splits each argument on $IFS and expands globs in it.
my_function $@
# Right. Preserves them as separate arguments.
my_function "$@"
The rule is simple enough to apply without thinking: quote every expansion
unless you have a specific reason not to. Prefer [[ ]] over [ ] in bash —
it does not word-split its operands, so an unquoted empty variable does not
turn a test into a syntax error.
[[ -f "$config" ]] || { echo "no config at $config" >&2; exit 1; }
[[ "$env" == "prod" ]] && echo "careful"
Functions
deploy() {
local environment="$1"
local version="${2:-latest}"
echo "Deploying ${version} to ${environment}" >&2
# ...
}
local is not optional. Without it, every variable is global, and a loop
counter named i inside a function silently destroys the i of whatever
called it. Send progress messages to stderr with >&2 so a caller can capture
the function's real output with $(...) without your log lines in it.
Exit codes
Your script's exit status is the only thing a scheduler, a CI runner, or Docker sees. Zero means success; anything else is a failure, and some values already mean something:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General failure |
| 126 | Found, but not executable |
| 127 | Command not found |
| 130 | Interrupted (Ctrl-C / SIGINT) |
A container whose entrypoint script exits 0 stops, and Kubernetes restarts it — see Container exits immediately. The exit code explainer decodes anything above 128.
Cron
Cron is where working scripts go to fail, for one reason: it does not give
you your shell environment. No PATH beyond a minimal default, no profile,
no nvm, no virtualenv.
# Absolute paths, redirect both streams, and a PATH you set yourself.
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * /opt/app/scripts/backup.sh >> /var/log/backup.log 2>&1
2>&1 is not optional either. Without it the error output goes to cron's mail
spool, which on most servers goes nowhere, and a job that has been failing for
six weeks looks exactly like one that has been working.
Use the cron builder rather than counting fields.
Windows line endings
Added 24 October 2026. A script saved on Windows ends every line with a carriage return as well as a newline, and that invisible character breaks it in two different ways. Run it directly, and the first line names an interpreter that does not exist:
bash: ./deploy.sh: /bin/bash^M: bad interpreter: No such file or directory
Run it as bash deploy.sh instead, and it starts, which is worse. Every line
still ends in a carriage return, so cd /tmp asks for a directory called
/tmp followed by that character. Reproduced on bash 3.2.57: the cd failed
with cd: /tmp: No such file or directory, the script carried on in the
directory it started in, and it exited 0.
Check for it, and strip it:
file deploy.sh # "... with CRLF line terminators"
sed -i 's/\r$//' deploy.sh # GNU sed; on macOS: sed -i ''
1. **No `set -euo pipefail`.** The script continues after a failure and exits
0. Whatever ran it records a success.
2. **Unquoted expansions.** Everything works until a path contains a space or a
variable is empty.
3. **Parsing `ls`.** Use a glob (`for f in ./*.log`) or `find -print0` with
`read -d ''`. Filenames can contain newlines.
4. **`cd` without checking.** `cd "$dir" || exit 1`. A failed `cd` followed by
`rm -rf ./*` runs in whatever directory you were already in.
5. **Assuming GNU tools.** `sed -i` takes an argument on BSD and macOS and does
not on GNU. If the script runs in both places, test in both.
## If this did not work
- **"command not found" only under cron or in CI.** `PATH`. Print `echo "$PATH"`
from inside the failing context rather than from your terminal.
- **The script works when you run it, fails as an entrypoint.** The container
image probably has no bash, only `sh`. Either install bash or write POSIX.
- **`set -e` did not stop it.** Check the list above — it is almost certainly a
pipeline, a condition, or a function called in a condition.
- **It is subtly wrong and you cannot see why.** Run `shellcheck` on it.
It catches quoting and expansion mistakes faster than reading does.
## Next steps
Add `set -euo pipefail` and a `trap ... EXIT` cleanup to the script you run
most often, then run `shellcheck` over it. On a script of any age, both will
find something.
## Related on this site
- [Container exit codes](/tools/exit-codes) — What your script's exit status means to whatever ran it.
- [Cron builder](/tools/cron-builder) — Build the schedule rather than guessing at the fields.
- [Container exits immediately](/problems/docker-container-exits-immediately) — When your entrypoint script is the reason.
s/\r$//' deploy.sh
Then stop it coming back, whatever each person's editor and Git settings are:
printf '*.sh text eol=lf\n' >> .gitattributes
git add .gitattributes && git add --renormalize -- '*.sh'
git commit -m "Shell scripts use LF"
Reproduced with git 2.50.1: the repository then stores LF and a fresh clone
runs. Your own copy keeps its carriage returns until the file is checked out
again. Keep --renormalize limited to the scripts, as above: given ., it also
stages any other file you have edited (reproduced on git 2.50.1). More on the
error itself:
bad interpreter: No such file or directory.
Common mistakes
- No
set -euo pipefail. The script continues after a failure and exits 0. Whatever ran it records a success. - Unquoted expansions. Everything works until a path contains a space or a variable is empty.
- Parsing
ls. Use a glob (for f in ./*.log) orfind -print0withread -d ''. Filenames can contain newlines. cdwithout checking.cd "$dir" || exit 1. A failedcdfollowed byrm -rf ./*runs in whatever directory you were already in.- Assuming GNU tools.
sed -itakes an argument on BSD and macOS and does not on GNU. If the script runs in both places, test in both.
If this did not work
- "command not found" only under cron or in CI.
PATH. Printecho "$PATH"from inside the failing context rather than from your terminal. - The script works when you run it, fails as an entrypoint. The container
image probably has no bash, only
sh. Either install bash or write POSIX. set -edid not stop it. Check the list above — it is almost certainly a pipeline, a condition, or a function called in a condition.- It is subtly wrong and you cannot see why. Run
shellcheckon it. It catches quoting and expansion mistakes faster than reading does.
Next steps
Add set -euo pipefail and a trap ... EXIT cleanup to the script you run
most often, then run shellcheck over it. On a script of any age, both will
find something.
Related on this site
- Container exit codes — What your script's exit status means to whatever ran it.
- Cron builder — Build the schedule rather than guessing at the fields.
- Container exits immediately — When your entrypoint script is the reason.
Tagged with
Enjoyed this article?
Get more DevOps insights delivered to your inbox.
Get new posts by email
Subscribe to get an email when a new blog post is published. Skip anytime.
No spam, unsubscribe anytime.
Related Posts
Discussion
0 comments
Sign in to join the conversation.
Be the first to comment
Start a conversation about this post



