Bash Standards
How a shell script behaves when something goes wrong: it stops, it cleans up, and it says why. Every rule here exists because the default shell behaviour is to carry on after a failure and produce a half-finished result.
Baseline: Bash 5.3 (verified locally) and ShellCheck current stable. Target Bash 4.4 or newer for anything that must run on older distributions, and say so in the script header.
When to activate
- Writing or reviewing a
.shor.bashscript. - Adding argument parsing, a usage block, or exit codes to an existing script.
- Fixing a script that silently continues after a failed command.
- Hardening a script against word splitting, globbing, or an unset variable.
- Wiring ShellCheck into CI, or clearing its findings.
- Writing a shell wrapper that starts a long-running service.
When not to activate
- Scripting on Windows or writing cmdlets and modules, use
powershell. - Writing the CI workflow YAML the script runs inside, use
deployment-patterns. - Writing the Dockerfile or entrypoint the script ships in, use
docker-patterns. - Driving infrastructure configuration that a playbook should own, use
ansible. - Writing Python instead because the script outgrew the shell, use
python-patterns.
Strict mode on every script
Enable strict mode immediately after the shebang so an unset variable, a failed command, or a broken pipe stops the script instead of poisoning the rest of the run.
Pass:
set -euo pipefail
Fail:
set -e
- Ref: http://redsymbol.net/articles/unofficial-bash-strict-mode/
- Set
IFS=$'\n\t'alongside it so word splitting no longer happens on spaces. - Disable
nounsetwithset +uonly around an optional positional check, then re-enable it withset -u. - Trap
ERRandEXITto run cleanup, so temp files and locks are removed even when the script crashes.
Quote every expansion
An unquoted expansion is split on whitespace and glob-expanded. That is the single most common shell defect.
Pass:
rm -f -- "$target_file"
Fail:
rm -f $target_file
- Validate required external commands with
command -vbefore any logic runs. - Validate arguments at entry, print usage, and exit 2 when one is missing or invalid.
- Use
[[ ]]rather than[ ]. It does not word-split its operands and it supports regex matching.
Script structure template
Every non-trivial script follows one shape: header block, strict mode, constants, logging helpers, cleanup trap,
argument parsing, main, then a single main "$@" call. The full copyable template and the reasoning behind each
piece live in references/script-template.md.
The header comment block carries the script name, a one-sentence description, the usage line, every option, every
exit code, and the minimum Bash version. usage prints it back, so the two can never drift.
Pass:
usage() { grep '^#' "$0" | sed 's/^# \?//'; exit 0; }
Fail:
usage() { echo "usage: script.sh [-v] <path>"; exit 0; }
Doc Comments
Default to none. A comment block above a function is usually a sign that the code failed to explain itself. Before writing one, extract the unclear block into a well-named function, rename the arguments so they carry their own meaning, and tighten the types. Do that first and most comment blocks have nothing left to say, which is the outcome you want. Code that explains itself cannot go stale, a comment can.
When one is still genuinely needed, the prose is capped at five lines and is usually one. Every note you add about an argument, the output, or an exit status is capped at one line and only appears when it genuinely adds something: if the note does not fit on a single line, shorten it or drop it. Four rules decide what goes in. Shell has no signature at all, no types and no named parameters, so the little that is worth saying is worth saying precisely.
- Prose. One sentence saying what it does, then only what a caller cannot infer from the signature. Nothing more.
- Describe an argument only when the name at the call site does not already convey it, meaning units, a valid range,
whether it may be empty, or who removes it afterwards.
$1 - the vaultis noise, delete it. - Describe what the function writes to stdout only when it is non-obvious, and say so explicitly when it writes nothing but sets a global.
- Describe every non-zero exit status a caller can act on, always. Nothing in shell declares them, so this one is genuinely contract rather than decoration.
Going past the five-line prose cap is allowed only when the contract genuinely cannot be stated in fewer lines, for example a documented state machine, an ordering requirement, or a concurrency guarantee. It is an exception you justify in review, not a budget to spend. The one-line cap on a note line has no exception at all: shorten it or delete it.
Pass, one line, then only what the call site cannot show:
# Rotates the vault backups and prints the paths it removed.
# $2 is an age in days, values above 3650 are rejected.
# Exits 3 when the vault directory is missing.
rotate_backups() { :; }
Fail, restates the function name and numbers the arguments for no gain:
# This function rotates backups.
# $1 - the vault
# $2 - the days
rotate_backups() { :; }
Argument parsing
Use getopts for single-character flags and a manual case loop for long options. Always implement -h and
--help, printing usage and exiting 0.
Pass:
while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage ;; -v|--verbose) verbose=1; shift ;; *) break ;; esac; done
Fail:
verbose="$1"
Exit codes
Exit 0 for success, 1 for a general error, 2 for misuse such as a bad argument or a missing dependency. Codes 3 to
125 are per-script and documented in the header. Never exit from inside a function, return a code and let the
caller decide.
Pass:
validate_input() { [[ -n "${1:-}" ]] || return 2; }
Fail:
validate_input() { [[ -n "$1" ]] || exit 2; }
Temporary files
Create every temp path with mktemp and remove it in the cleanup trap. A hardcoded path in /tmp is a collision and
a symlink attack waiting to happen.
Pass:
tmp_dir="$(mktemp -d)"
Fail:
tmp_dir="/tmp/my-script.tmp"
Secret handling
Read secrets from a file or an environment variable scoped to the subprocess. Never accept one as a positional
argument, it is visible in ps output to every user on the host, and never echo or log one.
Pass:
API_TOKEN="$(< /run/secrets/api_token)" ./deploy.sh
Fail:
./deploy.sh --token "sk-live-1234567890"
Portability and ShellCheck
Run ShellCheck on every script in CI with a zero-warning policy. Suppress a rule only with an inline
# shellcheck disable=SCxxxx comment that states the justification.
Pass:
shellcheck --severity=style --external-sources scripts/*.sh
Fail:
shellcheck --severity=error scripts/deploy.sh || true
- Ref: https://www.shellcheck.net/
- Document the minimum Bash version in the header, and flag Bash-only features (arrays,
[[ ]],declare) when the script might be sourced from a POSIXsh.
Startup readiness log
For a shell-implemented long-running service (a log aggregator, a polling daemon, an init wrapper around a child
process), cat the readiness banner immediately before exec or the main loop. The universal convention, the ANSI
Shadow banner, the section order, and the probe rules live in
observability-and-logging/references/startup-readiness-log.md.
Probe each dependency with a bounded timeout and surface only the status marker. Capture the exit code and send the probe's own stderr to a debug log.
Pass:
probe() { curl --silent --output /dev/null --max-time 2 --connect-timeout 2 "$1" && echo "$1 [Connected]" || echo "$1 [FAILED]"; }
Fail:
curl "$dependency_url"
Reference files
| Open this | For |
|---|---|
| references/script-template.md | Starting a new script, or checking that an existing one has the header, trap, and logging pieces |
| ../observability-and-logging/references/startup-readiness-log.md | Writing the readiness banner a shell-run service prints when it comes up |
Related skills
powershellfor the same standards on Windows.observability-and-loggingfor the readiness banner and the logging conventions it follows.docker-patternsfor entrypoint scripts and container health checks.deployment-patternsfor the CI workflow that runs ShellCheck and the script itself.ansiblewhen the script is really configuration management in disguise.
Checklist
-
set -euo pipefailandIFS=$'\n\t'immediately after the shebang. - Every variable expansion quoted, every test using
[[ ]]. - Required commands checked with
command -vbefore any logic. - Arguments validated at entry,
-hand--helpimplemented, usage matches the header block. -
trap cleanup EXITremoves every temp file and lock. - Temp paths created with
mktemp, never hardcoded. - No secret in a positional argument, an echo, or a log line.
- Exit codes documented in the header, functions return rather than exit.
- ShellCheck passes with zero findings, every suppression justified inline.