Great Stack to Doesn't Work — Bonus
10 Bash Scripting Golden Rules
Because your deployment script is production code whether you admit it or not.
1. Start every script with set -euo pipefail.
#!/usr/bin/env bash
set -euo pipefail
-e: Exit on any command failure. Without it, a failed rm or cp is silently ignored and the script continues with corrupted state.
-u: Treat undefined variables as errors. $UNSET_VAR expands to empty string by default. With -u, it's a hard error. This catches typos ($DATABSE_URL instead of $DATABASE_URL) before they reach production.
-o pipefail: A pipeline fails if any command in it fails. Without it, bad_command | grep something returns grep's exit code, hiding bad_command's failure.
2. Quote your variables. Always.
# BAD: breaks if filename has spaces
rm $file
# GOOD: works with any filename
rm "$file"
# BAD: word splitting nightmare
for f in $files; do
# GOOD: preserves entries with spaces
for f in "${files[@]}"; do
Unquoted variables undergo word splitting and glob expansion. A filename with spaces becomes two arguments. A variable containing * expands to every file in the directory.
3. Never use eval.
eval takes a string and executes it as a command. It's the rm -rf / of bash programming — it works until someone puts something unexpected in that string.
# DANGEROUS: if $user_input contains "; rm -rf /"
eval "echo $user_input"
# SAFE: use arrays for dynamic commands
cmd=("docker" "run" "--rm" "$image")
"${cmd[@]}"
If you think you need eval, you almost certainly need an array instead.
4. Use ShellCheck. Non-negotiable.
ShellCheck catches quoting errors, undefined variables, deprecated syntax, and common pitfalls statically. Run it in CI.
shellcheck myscript.sh
It finds bugs you'd never catch in code review. Enable it as a pre-commit hook and you'll wonder how you lived without it.
5. Clean up with trap.
Temporary files, background processes, lock files — if your script creates them, it must clean them up, even on failure.
cleanup() {
rm -f "$TEMP_FILE"
kill "$BG_PID" 2>/dev/null || true
}
trap cleanup EXIT
TEMP_FILE=$(mktemp)
some_command > "$TEMP_FILE" &
BG_PID=$!
trap ... EXIT fires on normal exit, error exit, and most signals. No more orphaned temp files.
6. Use process substitution instead of temp files.
# OLD: write to temp, read from temp
command1 > /tmp/result.txt
command2 < /tmp/result.txt
# BETTER: no temp file needed
command2 < <(command1)
# COMPARE TWO COMMANDS:
diff <(sort file1) <(sort file2)
<(command) creates a virtual file descriptor. No temp files to clean up. No race conditions.
7. Use parameter expansion instead of external commands.
# SLOW: spawns a subprocess
filename=$(basename "$path")
extension=$(echo "$file" | sed 's/.*\.//')
# FAST: pure bash
filename="${path##*/}"
extension="${file##*.}"
dirname="${path%/*}"
without_ext="${file%.*}"
# Default values
db_host="${DB_HOST:-localhost}"
db_port="${DB_PORT:-5432}"
Each $(...) forks a subprocess. In a loop processing 10,000 items, the subprocess overhead dominates. Parameter expansion is instant.
8. Use arrays properly.
# WRONG: space-delimited string
files="file one.txt file two.txt"
# RIGHT: proper array
files=("file one.txt" "file two.txt")
# Iterate safely
for f in "${files[@]}"; do
echo "Processing: $f"
done
# Pass as arguments
command "${files[@]}"
# Append
files+=("file three.txt")
# Length
echo "${#files[@]}"
Arrays preserve elements with spaces, newlines, and special characters. Strings don't.
9. Use here-docs for multi-line strings.
# HERE-DOC: variables expanded
cat << EOF
Hello $USER,
Today is $(date).
Your home is $HOME.
EOF
# HERE-DOC with quotes: no expansion (literal)
cat << 'EOF'
This $variable is not expanded.
Neither is $(this command).
EOF
# HERE-STRING: one-liner
grep "pattern" <<< "$variable"
Here-docs are cleaner than escaped multi-line echo statements and more readable than concatenated strings.
10. Test with Bats.
Bats (Bash Automated Testing System) is a testing framework for bash scripts.
# test_deploy.bats
@test "deployment script requires ENVIRONMENT variable" {
unset ENVIRONMENT
run ./deploy.sh
[ "$status" -eq 1 ]
[[ "$output" == *"ENVIRONMENT is required"* ]]
}
@test "deployment script validates environment name" {
ENVIRONMENT="invalid" run ./deploy.sh
[ "$status" -eq 1 ]
[[ "$output" == *"must be staging or production"* ]]
}
If your bash script is important enough to run in production, it's important enough to test. Bats makes it simple.
Over to You
Which bash scripting mistake has bitten you the hardest? Do you test your bash scripts — and if so, how?
If you enjoyed this, I write about production engineering, AI systems, and the messy reality of building software at scale.
Follow me:
This is part of the **Great Stack to Doesn't Work* series — a survival guide for when everything goes wrong in production. Follow the series to catch every episode.*
Top comments (1)
One lesson production environments taught me is that bash scripts are often treated as “temporary glue code” right until they become mission-critical. I’ve seen outages caused by missing quotes, silent pipeline failures, and scripts that worked perfectly for years until a filename contained an unexpected space.I’d add one more rule: treat deployment and automation scripts as production software. Give them code reviews, testing, versioning, and observability just like application code.The cost of a bug in a deployment script is often much higher than the cost of a bug in the application itself.