| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10ShellCheck 0.10.0coreutils 9.5-8.el10_2 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user for most examples; useradd and /etc/profile idempotency patterns need appropriate admin privileges |
| Scope | Production Bash checklist synthesis: tool choice, quoting, arrays, validation, I/O contracts, error handling, strict mode, traps, mktemp, idempotency, output parsing, secrets, locks, timeouts, linting, and maintainability. Each item links to a focused tutorial — this page does not retutorial strict mode, traps, or logging. |
| Related guides | Shell scripting course hub |
Production Bash scripts need explicit contracts for arguments, output, failures, cleanup, and concurrent runs. The checklist below focuses on decisions that prevent common operational failures, with focused guides linked where a topic needs deeper treatment.
Each section is a short decision note with a bad versus good contrast — use the linked tutorials when you need command-by-command depth.
Production Bash Checklist
Scan this table before you ship or review a script. Each row maps to a section below and a deeper guide on this site.
| Practice | Prevents | Deep dive |
|---|---|---|
| Choose Bash only while it fits | Staying in Bash after complexity outgrows orchestration | §1 |
| Arrays for argument lists | Word-splitting bugs and broken forwarding | §2 |
| Deliberate quoting | Empty splits, glob surprises, path breaks | §3 |
| Validate inputs and dependencies | Late failures deep in the script | §4 |
| stdout / stderr / exit contract | Parsers breaking on log noise | §5 |
| Explicit expected-error handling | grep exit 1 treated as disaster |
§6 |
| Strict mode with caveats | Silent failures and unset variables | §7 |
trap cleanup after resources |
Orphan temp files and mounts | §8 |
mktemp for temp paths |
Predictable /tmp races |
§9 |
| Idempotent admin steps | Double-run damage from cron | §10 |
| Structured data sources | Fragile ls and grep on JSON |
§11 |
| Secrets out of logs and argv | Credential leaks in ps and journals |
§12 |
| Locks for scheduled jobs | Overlapping backup or sync runs | §13 |
| Timeouts on network calls | Hung cron jobs forever | §14 |
| ShellCheck, format, tests | Regressions after small edits | §15 |
1. Choose Bash Only While Bash Fits the Job
Bash excels when control flow stays small and mostly orchestrates existing CLI tools. Move on when business logic, state models, concurrency, or complex data manipulation dominate the script — line count alone is not a reliable signal, but growing nested JSON transforms, parallel workers, and a full unit-test matrix are.
| Reach for Bash when | Reach for something else when |
|---|---|
You wrap kubectl, dnf, rsync, or curl with clear steps |
You parse complex JSON or build an API client |
| The control flow stays small with focused functions | You need typed modules, packages, or unit tests at scale |
/bin/bash is already on the target hosts |
You need cross-platform logic beyond GNU/Linux admin |
Bad — business logic and HTTP client in one growing script:
# curl, jq, and string munging with no tests and no clear exit criteriaGood — Bash invokes a tool that owns the hard part:
deploy-kit apply --env production --file policy.yamlKeep functions small with a clear status and output contract when you stay in Bash:
read_config() {
local cfgfile=$1
[[ -f $cfgfile ]] || return 2
# stdout: config payload only
cat "$cfgfile"
}Failure prevented: unmaintainable Bash that should have been Python, Go, or a configuration tool from the start. Compare interpreters and portability in Bash vs sh before you lock in Bash-only syntax.
2. Treat Arguments as Arrays, Not Strings
Command lines are arrays of words. Storing them in one unquoted string reintroduces word splitting when you expand them again.
Bad — word splitting breaks a path that contains spaces:
config="/etc/app/prod config.conf"
args="--config $config --verbose"
cmd $argscmd receives four words (--config, /etc/app/prod, config.conf, --verbose) instead of three.
Good — build an array and expand it with "${args[@]}":
config="/etc/app/prod config.conf"
args=(--config "$config" --verbose)
cmd "${args[@]}"Forward script arguments unchanged with "$@", not $*:
main() {
run_step "${@}"
}
main "$@"Failure prevented: broken paths, dropped empty arguments, and functions that see fewer parameters than the caller passed. See Bash arrays for construction patterns and script multiple arguments for forwarding into functions and SSH.
3. Quote Expansions Deliberately
You do not quote every token blindly — you quote expansions where word splitting or globbing would change meaning.
Bad — word splitting when name is empty or contains spaces:
rm -f $tmpdir/report-$name.logGood — one path per argument:
rm -f "$tmpdir/report-${name}.log"Leave intentional globbing unquoted on the pattern side only:
shopt -s nullglob
for f in "$srcdir"/*.yaml; do
deploy "$f"
doneFailure prevented: rm targeting wrong files, loops that skip or merge words, and silent empty expansions. The full rules live in Bash single vs double quotes and Bash globbing.
4. Validate Inputs and Dependencies
Fail fast at the top with clear stderr messages. Do not discover a missing binary halfway through a destructive path.
Bad — assumes kubectl exists and CONFIG is set:
kubectl apply -f "$CONFIG"Good — check dependencies and required variables before side effects:
if ! command -v kubectl >/dev/null 2>&1; then
echo "kubectl not found in PATH" >&2
exit 1
fi
if [[ -z ${CONFIG:-} ]]; then
echo "CONFIG is required" >&2
exit 1
fi
if [[ ! -f $CONFIG ]]; then
echo "CONFIG file missing: $CONFIG" >&2
exit 1
fiFor scripts with several short options, use Bash getopts rather than repeatedly shifting and hand-parsing $@. Validate option arguments before side effects. If the script needs GNU-style long options, choose and document a parser strategy rather than growing an ad-hoc case loop indefinitely.
Failure prevented: partial deploys, misleading errors ten steps later, and scripts that succeed only on your laptop layout. Pair file checks with Bash check if file exists patterns when validation grows beyond one path.
5. Define stdout, stderr, and Exit Codes
Callers and cron wrappers need a contract:
- stdout — machine-readable data only when the script is parsed
- stderr — human diagnostics and progress
- exit code —
0success; non-zero with a documented meaning when possible
Bad — warnings on stdout break downstream jq:
echo "Starting sync..."
curl -fsS "$url"Good — keep stdout clean; log to stderr:
log() { printf '%s\n' "$*" >&2; }
log "Starting sync..."
curl -fsS "$url"Document whether partial success uses a distinct exit code (for example 2 for validation errors). Bash exit codes and Bash script logging cover levels, tee, and journald capture.
6. Handle Errors Explicitly
set -e does not make every failure fatal. Expected non-zero statuses still need branches.
Bad — treats grep exit 1 (no match) as a script-killing error under errexit:
set -e
grep -q "$pattern" "$file"
echo "found"Good — distinguish no match from real errors (grep exit 1 versus >1):
if grep -q "$pattern" "$file"; then
echo "found"
else
status=$?
if (( status == 1 )); then
echo "not found" >&2
else
echo "grep failed with status $status" >&2
exit "$status"
fi
fiFailure prevented: flaky pipelines, swallowed errors in if, and retries that never run. Use the patterns in Bash error handling for trap ERR, retries, and batch partial failure.
7. Use Strict Mode with Known Caveats
set -euo pipefail is a strong default for many scripts, not a substitute for the error design in §6.
Bad — strict mode added without reading exceptions:
set -euo pipefail
# ... later: unchecked pipeline in if, unset optional flag, etc.Good — strict preamble plus documented exceptions:
set -euo pipefail
# Expected non-zero statuses such as grep -q or diff -q
# still need explicit control flow.Do not change global IFS as part of a generic strict-mode recipe; set it locally only when you intentionally need different field splitting. Arrays and quoted expansions from §2 and §3 are the preferred fix for most word-splitting problems.
Know where set -e does not exit: commands in if tests, || chains, and some subshell contexts. Full semantics and inherit_errexit are in Bash strict mode.
8. Clean Up with trap
Register cleanup as soon as you create a resource — temp directory, mount, or background job — not in a distant EXIT block you might forget to update.
Bad — cleanup only on the happy path:
tmpdir=$(mktemp -d)
do_work "$tmpdir"
rm -rf "$tmpdir"Good — trap on EXIT (and signals when needed) right after allocation:
tmpdir=$(mktemp -d) || exit 1
trap 'rm -rf "$tmpdir"' EXIT
do_work "$tmpdir"Failure prevented: filled /tmp, stale mounts, and orphaned lock files after set -e exits mid-run. Signal semantics and trap ERR pairing are in Bash trap.
9. Use mktemp
Never invent /tmp/myapp.$$ and open it later — another process can win the race.
Bad — predictable path:
tmp="/tmp/myapp.$$"
echo data >"$tmp"Good — atomic create and immediate trap:
tmp=$(mktemp) || exit 1
trap 'rm -f "$tmp"' EXIT
printf '%s\n' "$data" >"$tmp"Failure prevented: symlink attacks, clobbered files, and parallel runs stepping on each other. See Bash mktemp for directory templates, TMPDIR, and why mktemp -u is unsafe.
10. Design for Idempotency
Admin automation from cron or configuration management should survive a second run without damage.
Bad — always appends or creates blindly:
useradd deploy
echo 'export PATH=/opt/bin:$PATH' >> /etc/profileGood — test state or use idempotent tools (these patterns need admin privileges):
if ! id deploy &>/dev/null; then
useradd deploy
fi
line='export PATH=/opt/bin:$PATH'
grep -qxF "$line" /etc/profile || printf '%s\n' "$line" >> /etc/profilePrefer mkdir -p, install -D, kubectl apply, or Ansible modules that already encode create-or-update semantics.
Failure prevented: duplicate users, doubled config lines, and partial reruns that leave the system worse than before.
11. Avoid Fragile Output Parsing
Human-oriented command output changes between versions, locales, and column widths.
Bad — parsing ls for file names:
for f in $(ls *.log); do
gzip "$f"
doneGood — use globs or find with structured tests:
shopt -s nullglob
for f in *.log; do
gzip -- "$f"
doneBad — grep and sed on JSON:
token=$(curl -fsS "$url" | grep -o '"token":"[^"]*"')Good — separate transport from parsing so HTTP failures and missing JSON fields are distinguishable:
response=$(curl -fsS "$url") || exit 1
token=$(jq -er '.token' <<<"$response") || exit 1jq -e rejects null or false results from a missing .token field. If you pipe curl directly into jq, enable pipefail when an upstream failure must fail the pipeline — without it, the pipeline status comes from jq, not curl.
Failure prevented: broken loops on weird filenames, failed deploys after a CLI reformats tables, silent null tokens, and curl errors masked by a piped jq stage.
12. Protect Secrets and Debug Output
Keep secrets out of command-line arguments, logs, xtrace output, and source code. Prefer each program's supported credential file, file descriptor, agent, or secret-store integration. Passing the path to a protected credential file is different from expanding the secret itself into argv.
Bad — password on the command line and in xtrace:
set -x
some_command --password="$DB_PASS" --config "$APP_CONFIG"Good — pass a credential path or use a tool-supported mechanism; never expand the secret into argv:
# Prefer a tool-supported credential file, file descriptor,
# stdin, agent, or secret-store integration when available.
some_command --config "$CREDENTIAL_FILE"The exact mechanism varies by program (mysql option files, curl --netrc-file, Kubernetes secrets mounted as files, and so on). Environment variables are not automatically a secure secret store either: they are inherited by child processes and may be exposed through process-environment inspection, debugging, crash diagnostics, or CI tooling depending on the system.
Failure prevented: credentials in ps, shell history, centralized logs, and CI artifacts. Keep secrets off stderr and out of xtrace output; the logging patterns in §5 apply the same redaction rules to trace output.
13. Prevent Overlapping Scheduled Runs
Long cron jobs need a mutex so a second start does not pile on the first.
Bad — hope the job finishes before the next cron tick:
#!/usr/bin/env bash
heavy_syncGood — non-blocking flock on a writable runtime path (use /var/lock/... only when install has already created a writable lock directory):
#!/usr/bin/env bash
lock_dir="${XDG_RUNTIME_DIR:-$HOME/.local/state/myapp}"
mkdir -p "$lock_dir"
exec 9>"$lock_dir/sync.lock"
flock -n 9 || { echo "already running" >&2; exit 0; }
heavy_syncTo verify contention, hold the lock in the background and try a second non-blocking acquire:
lock=$(mktemp)
flock "$lock" -c 'sleep 3' &
holder=$!
sleep 0.2
if flock -n "$lock" -c 'echo second instance'; then
echo "unexpected: lock acquired"
else
echo "already running"
fi
wait "$holder"
rm -f "$lock"already runningflock -n returns non-zero when it cannot immediately acquire the lock; util-linux uses status 1 by default for lock contention. Decide what that means to your scheduler. For a cron job where overlap is an expected no-op, the wrapper above logs already running and exits 0. If the caller must distinguish a skipped run from success or failure, use flock -E to choose a documented conflict exit code (for example flock -n -E 75 9). More patterns (PID files, pgrep) are in check if script is already running.
14. Add Network and Process Timeouts
A hung curl, ssh, or kubectl call should not block automation until someone kills the job manually.
Bad — no bound on remote work:
curl -fsS "https://slow.example/status"Good — timeout from GNU coreutils:
timeout 30s curl -fsS "https://slow.example/status"Test timeout in explicit control flow when set -e is enabled — commands used as an if condition are exempt from errexit termination:
if timeout 1s bash -c 'sleep 5'; then
status=0
else
status=$?
fi
printf 'exit: %d\n' "$status"exit: 124GNU timeout returns 124 when it terminates a command because the time limit expired. Treat that as a timeout in your error handling and combine with bounded retries for transient network blips as shown in §6.
15. Lint, Format, and Test
Static analysis catches quoting and unreachable paths before production. Tests catch regressions when someone "just fixes one line."
Check syntax before static analysis:
bash -n deploy.shRun ShellCheck on every script you ship and review the findings — zero warnings at every severity is not required when a suppression is intentional and documented:
shellcheck deploy.shA typical quoting issue looks like this:
In deploy.sh line 3:
echo $foo
^--^ SC2086 (info): Double quote to prevent globbing and word splitting.Add shfmt for consistent indentation when your team shares scripts. Minimal tests can be Bats files or CI jobs that run the script against a fixture directory — ShellCheck alone is not a test suite.
Failure prevented: shipping SC2086-style bugs, unreadable diffs, and "it worked on my machine" regressions. See debug Bash script for trace strategies that complement linting.
Complete Review Checklist
Use this list on every production script before merge or deploy:
- Bash still fits the problem — orchestration, not dominant business logic
- Shebang deliberately selects Bash:
#!/usr/bin/env bashwhen PATH-selected Bash is intended, or an absolute Bash path when deployment requires a fixed interpreter - Arguments use arrays and
"$@"/"${array[@]}"where lists matter - Expansions are quoted unless globbing or splitting is intentional
- Required env vars, paths, and binaries are validated up front
- stdout stays machine-clean; diagnostics go to stderr
- Exit codes are documented; expected non-zero paths are explicit
-
set -euo pipefail(or a documented subset) with known exceptions -
trapcleanup registered immediately after temp files, mounts, or FDs - Temp paths come from
mktemp, not predictable/tmpnames - Cron or CM steps are safe to run twice
- No
ls,awkon columns, or regex JSON parsing where structured tools exist - Secrets are not in argv, xtrace, or log lines
- Scheduled jobs use
flockor equivalent overlap protection - Network and long-running commands have timeouts
-
bash -npasses; ShellCheck findings reviewed; justified suppressions documented; formatter applied; representative execution tests pass - Complexity still justifies Bash — not dominated by data models, concurrency, or nested API clients
Summary
Production Bash is less about memorizing every set option and more about explicit contracts: what the script accepts, what it prints, how it fails, and what it cleans up. The checklist above groups the failures I see most often on servers — word splitting from unquoted expansions, overlapping cron, predictable temp paths, and secrets in trace output — with a short bad versus good contrast for each.
set -euo pipefail belongs in many scripts, but it does not replace branches for expected non-zero tools or careful pipeline design. Pair strict mode with explicit error branches, register trap cleanup as soon as resources exist, and keep temp files on mktemp instead of hand-rolled /tmp names.
When business logic, state models, or concurrency dominate the script, move the core to a language with libraries and leave Bash as orchestration. Until then, run bash -n and ShellCheck on every change and treat the complete review checklist as the last gate before production.
References
- GNU Bash Manual — Shell Builtin Commands
- ShellCheck wiki
- GNU Coreutils — timeout invocation
- util-linux flock(1)
- Bats — Bash Automated Testing System
Frequently Asked Questions
Is set -euo pipefail mandatory?
No, but it is a sensible default for many Bash scripts once you understand the exceptions. set -e does not exit on failures inside if tests or on the left side of ||. set -u breaks optional flags unless you use ${VAR:-} defaults. pipefail is not portable POSIX sh syntax. Treat the trio as a baseline you document, not a magic safety switch.
Should every variable be quoted?
Quote expansions where word splitting or pathname expansion would change behavior: "$var", "${array[@]}", "$@". Do not quote intentional globs (*.log), here-doc delimiters, or arithmetic contexts ($(( n + 1 ))). ShellCheck rule SC2086 is the usual reminder for missing quotes.
When should I stop using Bash?
Move on when the script needs rich data structures, non-trivial concurrency, many external HTTP or JSON calls, or a large automated test suite around business logic. Bash remains appropriate for wrapping CLIs, short server glue, and portable admin tasks that stay small and linear.
Is ShellCheck enough testing?
No. ShellCheck finds quoting, portability, and some logic issues statically. It does not prove your script behaves correctly on real inputs, bad networks, or partial failure. Add fixture tests, Bats cases, or CI dry-runs that execute the script against representative data.
What makes a script production-ready?
A production-ready script validates inputs, defines stdout and stderr roles, distinguishes expected tool statuses from real errors (for example grep exit 1 versus >1), cleans up with trap, avoids predictable temp paths, resists overlapping schedules, bounds remote calls with timeouts, keeps secrets out of argv and logs, passes bash -n, has ShellCheck findings reviewed, and documents its exit-code contract. Readable small functions and an honest decision about whether Bash still fits the workload matter as much as any single flag.

