Overview
Bash is forgiving in ways that bite in production. A script that works on a developer laptop fails silently in cron because a variable was empty, a path was relative, or a pipe swallowed the exit code. This page lists the defaults that make Bash scripts predictable. Read general-principles first.
set -euo pipefail at the top of every script
Three flags. They turn Bash from “keep going on errors” into “exit on the first failure.”
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'-e: exit on any command that returns non-zero.-u: exit when reading an unset variable. Catches typos like$pthfor$path.-o pipefail: the exit code of a pipeline is the rightmost non-zero exit code, not just the last one. Without this,cat missing.txt | grep fooreports success.
Set IFS to newline and tab so word splitting on unquoted expansions stops splitting on every space. Better still, quote your expansions.
Quote every variable
"$var" is the default. $var is the exception. Unquoted variables word-split on IFS and glob-expand on *, which corrupts filenames with spaces and matches files that look like flags.
# Bad
rm $tmpfile
# Better
rm -- "$tmpfile"Use "${var}" when an adjacent character would be parsed as part of the name ("${prefix}_log.txt"). Use -- before path arguments to commands so a filename starting with - does not become a flag.
Shellcheck in CI
shellcheck finds the bugs set -euo pipefail cannot. Run it on every script in CI.
- name: shellcheck
run: shellcheck --severity=warning scripts/*.shTreat shellcheck warnings as errors. The handful of false positives get a per-line disable with a comment explaining why:
# shellcheck disable=SC2086 # word splitting intended for flag list
ssh "$host" $flags 'uptime'Prefer [[ ]] over [ ]
Bash’s [[ ]] is the conditional you want. POSIX [ ] (the test builtin) is the one you tolerate when targeting /bin/sh.
[[ -n "$var" ]]does not word-split.[ -n $var ]does.[[ "$a" == abc* ]]supports glob matching.[ ]does not.[[ "$a" =~ ^[0-9]+$ ]]supports regex.&&and||work inside[[ ]].
Reach for [ ] only when the script’s shebang is #!/bin/sh and POSIX compatibility is a real requirement (Alpine BusyBox, system rc scripts).
Trap on EXIT for cleanup
Cleanup runs whether the script succeeds, fails, or is interrupted. Use a single EXIT trap.
tmpdir="$(mktemp -d)"
cleanup() { rm -rf -- "$tmpdir"; }
trap cleanup EXITFor long-running scripts, also trap INT and TERM to log the signal before exiting:
trap 'echo "interrupted" >&2; exit 130' INT TERMAvoid trap '' EXIT-style empty traps; they hide cleanup bugs. Avoid setting multiple EXIT traps; only the last one wins.
POSIX only when portability matters
Write Bash 5 by default. Use arrays, [[ ]], ${var/foo/bar}, and mapfile freely. Drop to POSIX sh only when the script must run somewhere Bash is not installed (a Debian slim container, an Alpine base, a BSD jail, a Dockerfile’s RUN against /bin/sh).
For multi-OS portability, write the script in a real language. A Python or Go binary travels better than a portable shell script.
Absolute paths in cron and systemd
A script that runs from cron, systemd, or launchd does not inherit the interactive shell’s PATH, working directory, or environment. Reference everything by absolute path.
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG="/var/log/myapp/job.log"
cd "$SCRIPT_DIR"
/usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin /usr/local/bin/myapp run >> "$LOG" 2>&1Set PATH explicitly inside the script. Use /usr/bin/env -i to start from an empty environment when the job must be reproducible. Log to a known file with >> and 2>&1; cron emails for unhandled output and you do not want surprise emails at 3 a.m.
One script does one thing
Every script has a single purpose stated in a comment block on line two. If a script has grown to dispatch on a case over five subcommands, split it into five scripts with a thin wrapper, or rewrite it in a language with a real CLI library. See general-principles on deletion over abstraction.