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 $pth for $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 foo reports 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/*.sh

Treat 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 EXIT

For long-running scripts, also trap INT and TERM to log the signal before exiting:

trap 'echo "interrupted" >&2; exit 130' INT TERM

Avoid 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>&1

Set 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.