Shell Scripting Best Practices: Write Production-Grade Bash
Writing a shell script that works is easy. Writing one that works reliably, securely, and maintainably — across different systems, users, and edge cases — is an entirely different skill.
Over the previous chapters, we've covered the building blocks of shell scripting. This chapter ties them together into a set of disciplined habits that separate hobby scripts from production-grade automation. Follow these ten best practices, and your scripts will be safer, clearer, and far easier to debug when things go wrong.
1. Always Use a Shebang
The shebang line tells the operating system which interpreter should execute your script. Without it, the script runs in the user's current shell — which might be sh, zsh, or fish — leading to subtle syntax errors and incompatible behavior.
The Portable Shebang
1#!/usr/bin/env bash
Why env bash instead of /bin/bash? Because Bash isn't always at /bin/bash. On macOS with Homebrew, BSD systems, or certain container images, it might live at /usr/local/bin/bash. The env utility searches the user's PATH to find the correct interpreter, making your script truly portable.
Other Common Shebangs
| Shebang | Use Case |
|---|---|
#!/usr/bin/env bash | Bash-specific scripts (recommended default) |
#!/bin/sh | POSIX-compliant scripts for maximum portability |
#!/usr/bin/env python3 | Python scripts |
#!/usr/bin/env node | Node.js scripts |
Never omit the shebang. A script without one relies on the parent shell, which is a recipe for inconsistent behavior.
2. Enable Strict Mode
The single most impactful line you can add to a Bash script is strict mode. It catches three entire categories of bugs automatically.
1#!/usr/bin/env bash 2set -euo pipefail 3IFS=$'\n\t'
What Strict Mode Does
| Flag | Behavior | Problem It Solves |
|---|---|---|
-e | Exit immediately on command failure | Scripts continuing after errors, causing cascading damage |
-u | Treat unset variables as errors | Silent empty expansions like rm -rf /$undefined_dir |
-o pipefail | Pipeline fails if any command fails | Hidden failures in `cmd1 |
IFS=$'\n\t' | Set Internal Field Separator to newline and tab only | Word splitting on spaces breaking filenames and strings |
Strict Mode in Action
1#!/usr/bin/env bash 2set -euo pipefail 3 4# Without -u: this would silently print an empty string 5echo "$UNDEFINED_VAR" # Script exits with error immediately 6 7# Without -e: cp would fail but the script would continue 8cp /missing/file.txt /dest/ # Script exits immediately 9 10# Without pipefail: grep failure would be hidden 11cat missing.txt | grep "pattern" | wc -l # Entire pipeline fails
Pro Tip: If you need to allow a specific command to fail without stopping the script, append
|| true:grep "pattern" file.txt || true
3. Quote Variables
Unquoted variables are the leading cause of shell scripting bugs. Word splitting and glob expansion can turn a harmless command into a destructive one.
The Danger of Unquoted Variables
1#!/bin/bash 2 3dir="My Documents" 4rm -rf $dir/ # Expands to: rm -rf My Documents/ 5 # Deletes "My" and "Documents" separately — catastrophic!
The Safe Approach
1#!/bin/bash 2 3dir="My Documents" 4rm -rf "$dir/" # Expands to: rm -rf "My Documents/" — correct behavior
When to Quote
| Context | Quote? | Example |
|---|---|---|
| Variable expansion | Always | "$filename" |
| Command substitution | Always | "$(command)" |
| Arrays | Always | "${array[@]}" |
| Arithmetic expansion | No | $((count + 1)) |
| Tilde expansion | No | ~user |
Golden Rule: When in doubt, quote it. The only things you shouldn't quote are intentional glob patterns and arithmetic expressions.
4. Use Meaningful Names
Code is read far more often than it's written. Descriptive names make your scripts self-documenting and reduce the cognitive load for anyone reading them — including yourself in six months.
Bad Naming
1#!/bin/bash 2 3x=10 4f() { ... }
Good Naming
1#!/bin/bash 2 3max_retries=10 4backup_database() { ... }
Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Variables | lowercase_with_underscores | backup_directory |
| Constants / Environment | UPPERCASE_WITH_UNDERSCORES | MAX_CONNECTIONS |
| Functions | lowercase_with_underscores | process_log_file() |
| Temporary variables | Short but clear | i, idx (only in tight loops) |
| Boolean-like | Prefix with is_, has_, should_ | is_valid, has_errors |
5. Add Comments
Comments should explain why, not what. The code itself shows what happens; comments should clarify intent, document assumptions, and warn about edge cases.
Script Header Template
1#!/usr/bin/env bash 2set -euo pipefail 3 4# ============================================================================== 5# Script Name: backup_database.sh 6# Description: Creates compressed backups of PostgreSQL databases with rotation. 7# Author: Tech3Space 8# Date: 2026-08-16 9# Usage: ./backup_database.sh [database_name] [backup_dir] 10# Dependencies: pg_dump, gzip, awk 11# ==============================================================================
Inline Comments
1# Use --clean to include DROP statements for idempotent restores 2pg_dump --clean "$db_name" > "$dump_file" 3 4# Retain only the last 7 days of backups to manage disk space 5find "$backup_dir" -name "*.sql.gz" -mtime +7 -delete
Avoid obvious comments.
x=5 # Set x to 5is noise.x=5 # Default retry limit per SLAis useful.
6. Validate Inputs
Never trust user input, environment variables, or external data. Validate everything before acting on it.
Check Argument Count
1#!/bin/bash 2set -euo pipefail 3 4if [ $# -lt 1 ]; then 5 echo "Usage: $0 <source_file> [destination_dir]" >&2 6 exit 1 7fi
Check File Existence and Type
1#!/bin/bash 2set -euo pipefail 3 4file="$1" 5 6if [ ! -f "$file" ]; then 7 echo "Error: File not found: $file" >&2 8 exit 1 9fi 10 11if [ ! -r "$file" ]; then 12 echo "Error: File not readable: $file" >&2 13 exit 1 14fi
Validate with Regular Expressions
1#!/bin/bash 2set -euo pipefail 3 4email="$1" 5 6if [[ ! "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then 7 echo "Error: Invalid email format: $email" >&2 8 exit 1 9fi
Validation Checklist
- Correct number of arguments provided
- Files exist and are readable/writable/executable as needed
- Directories exist (or can be created)
- Numeric values are actually numbers
- Strings match expected patterns (emails, IPs, dates)
- Required environment variables are set
- The script has necessary privileges
7. Use Functions
Functions are the foundation of modular, testable, and readable scripts. Break your logic into small, single-purpose functions with a clear main entry point.
Structure Your Script
1#!/usr/bin/env bash 2set -euo pipefail 3 4# ─── Configuration ─── 5readonly BACKUP_DIR="/backups" 6readonly RETENTION_DAYS=7 7 8# ─── Functions ─── 9backup_database() { ... } 10backup_files() { ... } 11cleanup_old_backups() { ... } 12 13# ─── Main ─── 14main() { 15 backup_database 16 backup_files 17 cleanup_old_backups 18} 19 20main "$@"
Why main "$@"?
Passing "$@" to main ensures all script arguments are forwarded into your function, making testing and reuse easier. It also clearly separates function definitions from execution.
Function Design Principles
- Single Responsibility: One function should do one thing
- Local Variables: Always use
localinside functions - Return Values: Use
echofor data,returnfor status codes - Error Handling: Functions should validate inputs and fail fast
8. Handle Errors Gracefully
Scripts fail. Networks drop, disks fill up, and permissions change. How your script responds to failure determines whether it's a minor hiccup or a production incident.
Inline Error Handling with ||
1#!/bin/bash 2set -euo pipefail 3 4mkdir -p /var/app/data || { 5 echo "Error: Failed to create directory /var/app/data" >&2 6 exit 1 7}
The die Function Pattern
1#!/bin/bash 2set -euo pipefail 3 4die() { 5 echo "[ERROR] $*" >&2 6 exit 1 7} 8 9warn() { 10 echo "[WARN] $*" >&2 11} 12 13# Usage 14[ -f "config.yml" ] || die "config.yml not found" 15[ "$EUID" -eq 0 ] || die "This script must run as root" 16 17cp source.txt dest.txt || die "Failed to copy source.txt"
Error Handling Best Practices
| Practice | Why It Matters |
|---|---|
Print errors to stderr (>&2) | Keeps stdout clean for data pipelines |
| Use meaningful exit codes | Lets calling scripts and CI/CD detect failure type |
| Fail fast | Stop immediately on error — don't propagate bad state |
| Log context | Include filenames, line numbers, and variable values |
| Clean up on failure | Use trap to remove temp files and locks |
9. Use Temporary Files Safely
Hardcoded temporary file paths are a security risk and a collision hazard. Two scripts running simultaneously could overwrite each other's data — or worse, a malicious actor could create a symlink at a predictable path.
The Safe Way: mktemp + trap
1#!/bin/bash 2set -euo pipefail 3 4TMPFILE=$(mktemp) 5trap 'rm -f "$TMPFILE"' EXIT 6 7# Use $TMPFILE safely... 8echo "Processing..." > "$TMPFILE"
The Dangerous Way (Never Do This)
1#!/bin/bash 2 3TMPFILE=/tmp/myscript.tmp # Dangerous! 4# Race conditions, symlink attacks, and collisions await
Temporary Directory Pattern
1#!/bin/bash 2set -euo pipefail 3 4TMPDIR=$(mktemp -d) 5trap 'rm -rf "$TMPDIR"' EXIT INT TERM 6 7# Work inside the temp directory 8cd "$TMPDIR" 9# ... do work ...
Always pair
mktempwith atrapcleanup. This guarantees removal even if the script is interrupted with Ctrl+C orkill.
10. Make Scripts Portable
Your script might run on Ubuntu today, but tomorrow it could be deployed on Alpine Linux, macOS, or a minimal Docker container. Portable scripts avoid hardcoded paths and check for dependencies.
Check for Required Programs
1#!/bin/bash 2set -euo pipefail 3 4command -v jq &>/dev/null || { 5 echo "Error: jq is required but not installed." >&2 6 exit 1 7} 8 9command -v curl &>/dev/null || { 10 echo "Error: curl is required but not installed." >&2 11 exit 1 12}
Avoid Hardcoded Paths
| Bad | Good |
|---|---|
#!/bin/bash | #!/usr/bin/env bash |
/bin/rm | rm (rely on PATH) |
/usr/bin/python3 | /usr/bin/env python3 |
Use POSIX Commands When Possible
If you need your script to run on sh (not just bash), stick to POSIX-compliant commands:
- Use
=instead of==in[ ]tests - Use
$(command)instead of backticks - Avoid arrays (not in POSIX
sh) - Use
command -vinstead ofwhich
Detect the Operating System
1#!/bin/bash 2set -euo pipefail 3 4if [[ "$OSTYPE" == "linux-gnu"* ]]; then 5 stat_cmd="stat -c%s" 6elif [[ "$OSTYPE" == "darwin"* ]]; then 7 stat_cmd="stat -f%z" 8else 9 echo "Unsupported OS: $OSTYPE" >&2 10 exit 1 11fi 12 13file_size=$($stat_cmd "$file")
Bonus: The Perfect Script Template
Here's a starter template that incorporates all ten best practices:
1#!/usr/bin/env bash 2set -euo pipefail 3IFS=$'\n\t' 4 5# ============================================================================== 6# Script Name: example.sh 7# Description: Brief description of what this script does. 8# Author: Tech3Space 9# Date: 2026-08-16 10# Usage: ./example.sh <arg1> [arg2] 11# Dependencies: dependency1, dependency2 12# ============================================================================== 13 14# ─── Configuration ─── 15readonly SCRIPT_NAME=$(basename "$0") 16readonly SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 17readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log" 18 19# ─── Functions ─── 20 21die() { 22 echo "[ERROR] $*" >&2 23 exit 1 24} 25 26log() { 27 echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" 28} 29 30check_dependencies() { 31 local deps=("curl" "jq" "awk") 32 for dep in "${deps[@]}"; do 33 command -v "$dep" &>/dev/null || die "$dep is required but not installed." 34 done 35} 36 37validate_inputs() { 38 if [ $# -lt 1 ]; then 39 echo "Usage: $SCRIPT_NAME <config_file>" >&2 40 exit 1 41 fi 42 43 local config_file=$1 44 [ -f "$config_file" ] || die "Config file not found: $config_file" 45 [ -r "$config_file" ] || die "Config file not readable: $config_file" 46} 47 48process() { 49 local input=$1 50 # Processing logic here 51 echo "Processed: $input" 52} 53 54# ─── Main ─── 55 56main() { 57 check_dependencies 58 validate_inputs "$@" 59 60 local config_file=$1 61 log "Starting $SCRIPT_NAME with $config_file" 62 63 process "$config_file" 64 65 log "Completed successfully." 66} 67 68# ─── Cleanup ─── 69 70cleanup() { 71 # Remove temp files, release locks, etc. 72 : 73} 74 75trap cleanup EXIT INT TERM 76 77# ─── Entry Point ─── 78 79main "$@"
Quick Reference: Best Practices Checklist
Before deploying any script, verify:
- Shebang is present and uses
#!/usr/bin/env bash - Strict mode is enabled:
set -euo pipefail - All variables are quoted when expanded:
"$var" - Names are descriptive and follow conventions
- Comments explain why, not what
- Inputs are validated (count, existence, format, permissions)
- Functions are small, focused, and use
localvariables - Errors are handled with
||blocks ordiefunctions - Temp files use
mktempwithtrapcleanup - Dependencies are checked with
command -v - Cleanup runs on
EXIT,INT, andTERMviatrap
These ten best practices aren't rules for rules' sake — they're hard-won lessons from production outages, security incidents, and late-night debugging sessions. Internalize them, apply them consistently, and your shell scripts will become tools you trust rather than code you fear.
This concludes our comprehensive shell scripting series. From your first "Hello, World" to production-ready automation, you now have the complete toolkit. Go build something great.
Series Complete — Thank You for Learning with Tech3Space!