Server

Writing a Bash Script That Won't Bite You

By Prabath Thalangama· October 24, 2026· 3 min read
#linux#automation#troubleshooting

Introduction

Most operational bash scripts are one unquoted variable away from disaster. A short set of habits — a strict-mode header, quoting everything, a cleanup trap — removes almost all of that risk without turning bash into a different language.

The header

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
  • -e — exit on any command that fails (non-zero). No more "the script kept going after cd failed and rm -rf ran in the wrong directory."
  • -u — error on an unset variable. Catches typos and rm -rf "$DIR/" when $DIR is empty (it becomes rm -rf /).
  • -o pipefail — a pipeline fails if any stage fails, not just the last. Without it, curl badurl | jq . succeeds because jq succeeded.
  • IFS=$'\n\t' — word-split only on newline and tab, not spaces. Filenames with spaces stop breaking loops.

Working with -e's edge cases

-e doesn't trigger for commands in if, &&, ||, or ! — that's by design. When you want to check a command that might fail:

if ! systemctl is-active --quiet nginx; then
    echo "nginx is down"
fi

# Capture a possibly-failing command without aborting
output=$(some_command) || output="default"

Quote everything

# Wrong — breaks on spaces, globs, empty values
cp $src $dst
for f in $(ls *.log); do rm $f; done

# Right
cp -- "$src" "$dst"
for f in *.log; do            # let the shell glob; no ls
    [ -e "$f" ] || continue   # handle "no matches"
    rm -- "$f"
done

"$@" (quoted) preserves each argument; $* and unquoted $@ don't. Use -- before filename arguments so a file named -rf isn't parsed as a flag.

Safe temp files and cleanup

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

# ...use "$tmpdir"...
# cleanup runs on normal exit, error (thanks to -e), or Ctrl-C

trap ... EXIT is the single most useful pattern — it guarantees cleanup no matter how the script ends. Add specific signals if you need different handling: trap 'echo interrupted; exit 130' INT.

Arguments and usage

usage() { echo "usage: $0 [-v] -e ENV SERVICE" >&2; exit 2; }

verbose=0 env=""
while getopts ":ve:h" opt; do
    case "$opt" in
        v) verbose=1 ;;
        e) env="$OPTARG" ;;
        h) usage ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

[ -n "$env" ] || usage
service="${1:?service name required}"

The classic footguns

  • rm -rf "$dir/" with $dir unset → -u catches it; also rm -rf "${dir:?}/" aborts if empty.
  • cd "$dir" && rm -rf * — if cd fails and there's no -e, you rm in the current directory. -e fixes it; cd "$dir" || exit 1 is explicit.
  • Parsing ls output — never. Glob, or find ... -print0 | xargs -0.
  • [ $x == "y" ] when $x is empty → [ == "y" ] → syntax error. Quote: [ "$x" == "y" ], or use [[ ]].
  • echo for anything with backslashes or leading - → use printf '%s\n'.
  • Comparing numbers with </> in [ ] (that's string comparison) — use (( x > 5 )) or [ "$x" -gt 5 ].

Lint it

shellcheck myscript.sh

ShellCheck catches every quoting and word-splitting bug above and explains each. Run it in CI. bash -n myscript.sh is a syntax-only check.

Verification

bash -n script.sh          # parse without running
bash -x script.sh          # trace every expanded command
shellcheck script.sh
  • Script "works" but does nothing on some inputs — an unquoted variable word-split, or a glob that matched nothing and was passed literally. -x shows the actual expanded command.
  • -e set but the script continues past a failure — the failing command is in a pipeline (need pipefail), or in an if/|| context (by design), or it's a function whose last command "succeeded."
  • Cleanup didn't run — the script was kill -9'd (uncatchable), or the trap was set after the failure point. Set traps early.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.