Shell/Bash Guide
Applies to: Bash 4+, POSIX sh, Automation Scripts, CI/CD Pipelines, Makefiles
Core Principles
-
Strict Mode Always: Every script starts with set -euo pipefail to fail fast on errors
-
Quote Everything: All variable expansions must be double-quoted to prevent word splitting and globbing
-
Explicit Over Implicit: Use [[ ]] for conditionals, local for function variables, named constants for magic values
-
Fail Loudly: Never swallow errors silently; use trap for cleanup and meaningful exit codes
-
ShellCheck Clean: All scripts pass shellcheck with zero warnings before commit
Guardrails
Shebang and Strict Mode
-
Every script: #!/usr/bin/env bash (or #!/bin/sh for POSIX)
-
Immediately follow with set -euo pipefail
-
Use set -x only for debugging, never in production scripts
-
POSIX scripts must not use bash-specific features ([[ ]] , arrays, local )
#!/usr/bin/env bash set -euo pipefail [[ "${TRACE:-}" == "1" ]] && set -x
Quoting
-
Always double-quote: "$var" , "$@" , "${arr[@]}" , "$(command)"
-
Single quotes for literals that must not be interpolated
-
Only omit quotes in arithmetic: $(( count + 1 ))
Correct
grep -r "$pattern" "$directory" for arg in "$@"; do process "$arg"; done
Wrong - word splitting and globbing bugs
grep -r $pattern $directory for arg in $@; do process $arg; done
Error Handling
-
Check return codes: if ! command; then handle_error; fi
-
Inline: critical_cmd || { echo "Failed" >&2; exit 1; }
-
Never use set +e (restructure logic instead)
-
Exit codes: 0 = success, 1 = general error, 2 = usage error
-
Errors to stderr: echo "Error: message" >&2
Portability
-
#!/usr/bin/env bash over #!/bin/bash (varies across systems)
-
command -v instead of which for executable checks
-
$(command) instead of backticks
-
printf over echo for portable output (flags/escapes differ)
Security
-
Never use eval (use arrays for dynamic command building)
-
Validate all external input (arguments, env vars, file contents)
-
mktemp for temp files, never hardcoded /tmp/myapp.tmp
-
No secrets in script files; read from environment or secret managers
-
umask 077 before creating sensitive files
Safe temp file
tmpfile="$(mktemp)" || exit 1 trap 'rm -f "$tmpfile"' EXIT
Never do this
eval "$user_input" # Command injection password="hunter2" # Hardcoded secret
Script Structure
#!/usr/bin/env bash set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
── Configuration (with defaults) ─────────────────────
LOG_LEVEL="${LOG_LEVEL:-info}" OUTPUT_DIR="${OUTPUT_DIR:-./output}"
── Functions ──────────────────────────────────────────
usage() { ... } cleanup() { ... } main() { ... }
── Traps & Entry ─────────────────────────────────────
trap cleanup EXIT main "$@"
Key Patterns
Parameter Expansion
db_host="${DB_HOST:-localhost}" # Default value api_key="${API_KEY:?Error: API_KEY required}" # Required (fail if unset)
filename="archive.tar.gz" name="${filename%%.}" # "archive" (longest suffix removal) ext="${filename#.}" # "tar.gz" (shortest prefix removal) path="/usr/local/bin/tool" dir="${path%/}" # "/usr/local/bin" base="${path##/}" # "tool" upper="${var^^}" # UPPERCASE (Bash 4+) lower="${var,,}" # lowercase (Bash 4+)
Trap for Cleanup
cleanup() { local exit_code=$? rm -f "$tmpfile" exit "$exit_code" # Preserve original exit code } trap cleanup EXIT trap 'echo "Interrupted" >&2; exit 130' INT TERM
Arrays (Avoid eval)
declare -a files=() files+=("first.txt" "second.txt") for file in "${files[@]}"; do echo "$file"; done
Build commands safely with arrays
cmd=(curl --silent --fail) [[ -n "${TOKEN:-}" ]] && cmd+=(--header "Authorization: Bearer $TOKEN") "${cmd[@]}" "$url"
Functions
process_file() { local file="$1" local -r max_lines=1000 local line_count line_count="$(wc -l < "$file")" if (( line_count > max_lines )); then echo "Warning: $file exceeds $max_lines lines" >&2 fi }
All variables inside functions must be declared local .
Here Documents
cat <<EOF # Interpolated Hello, $USER at $(hostname) EOF
cat <<'EOF' # Literal (no expansion) This $variable stays literal. EOF
Safe File Iteration
Handles spaces, newlines, special characters
while IFS= read -r -d '' file; do process "$file" done < <(find "$dir" -type f -name "*.log" -print0)
Simple globs (Bash 4+)
shopt -s nullglob globstar for file in "$dir"/**/*.sh; do process "$file"; done
Never use for file in $(find ...) -- it breaks on spaces.
Process Substitution
diff <(sort file1.txt) <(sort file2.txt)
Testing
bats-core
test/deploy.bats
setup() { export TMPDIR="$(mktemp -d)"; } teardown() { rm -rf "$TMPDIR"; }
@test "deploy requires environment argument" { run ./deploy.sh [ "$status" -ne 0 ] [[ "$output" == "Usage:" ]] }
Testing Standards
-
Test with bats-core (preferred) or shellspec
-
Test files in test/ or spec/ directory
-
Test names describe behavior: "deploy requires environment argument"
-
Use setup /teardown for temp dirs and fixtures
-
Coverage: >80% library functions, >60% scripts
-
Each test must be independent
Tooling
ShellCheck
shellcheck script.sh # Single file find . -name "*.sh" -exec shellcheck {} + # All scripts
Suppress with justification
shellcheck disable=SC2034 # Variable used by sourced script
unused_looking_var="value"
shfmt
shfmt -w -i 4 -bn script.sh # Format in place (4-space indent) shfmt -d -i 4 script.sh # Check only (diff output)
Essential Commands
shellcheck *.sh # Lint shfmt -d -i 4 *.sh # Check formatting bats test/ # Run tests bash -n script.sh # Syntax check (no execution)
References
For detailed patterns and examples, see:
- references/patterns.md -- Script templates, trap patterns, portable scripting examples
External References
-
Bash Reference Manual
-
POSIX Shell Specification
-
ShellCheck Wiki
-
Google Shell Style Guide
-
bats-core
-
shellspec
-
shfmt
-
Pure Bash Bible