Linux Shell Scripting Best Practices
The shebang line determines which interpreter executes your script. Use `#!/usr/bin/env bash` instead of `#!/bin/bash` for portability—it searches the user's PATH for bash rather than assuming a...
Key Insights
- Always use
set -euo pipefailat the start of your scripts to catch errors early and prevent silent failures that can corrupt data or leave systems in inconsistent states. - Quote all variable expansions (
"$var") unless you explicitly need word splitting—unquoted variables are the leading cause of bugs in shell scripts. - Invest in proper error handling with
trapfor cleanup and meaningful exit codes; a script that fails silently is worse than no script at all.
Script Structure and Shebang
The shebang line determines which interpreter executes your script. Use #!/usr/bin/env bash instead of #!/bin/bash for portability—it searches the user’s PATH for bash rather than assuming a fixed location.
Immediately after the shebang, set strict error handling modes. The combination set -euo pipefail is essential for production scripts:
set -e: Exit immediately if any command returns non-zeroset -u: Treat unset variables as errorsset -o pipefail: Fail if any command in a pipeline fails
Here’s a robust script template:
#!/usr/bin/env bash
#
# Description: Backup user directories to remote storage
# Author: ops-team@company.com
# Usage: ./backup.sh [-d directory] [-r remote_host]
#
set -euo pipefail
# Global configuration
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"
readonly TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
# Default values
BACKUP_DIR="${HOME}/backups"
REMOTE_HOST=""
This template establishes conventions immediately. The readonly declarations prevent accidental modification of critical paths, and using BASH_SOURCE[0] instead of $0 works correctly even when the script is sourced.
Variable Handling and Quoting
Variable quoting is where most shell scripting bugs originate. The rule is simple: always quote variable expansions unless you have a specific reason not to.
# WRONG - vulnerable to word splitting and glob expansion
file_list=$HOME/*.txt
for file in $file_list; do
echo $file
done
# CORRECT
file_list="$HOME"/*.txt
for file in $file_list; do
echo "$file"
done
# Better - use arrays for lists
file_list=("$HOME"/*.txt)
for file in "${file_list[@]}"; do
echo "$file"
done
Follow these naming conventions:
UPPERCASEfor environment variables and constantslowercasefor local variables and function parameters- Use
readonlyfor values that shouldn’t change - Use
localfor variables inside functions
# Environment/global constants
readonly MAX_RETRIES=3
readonly CONFIG_FILE="/etc/myapp/config"
process_file() {
local input_file="$1"
local line_count=0
while IFS= read -r line; do
((line_count++))
# Process line
done < "$input_file"
echo "$line_count"
}
The IFS= read -r pattern preserves leading/trailing whitespace and prevents backslash interpretation—critical for processing arbitrary input.
Error Handling and Exit Codes
Scripts should fail fast and clean up after themselves. Use trap to ensure cleanup code runs even when errors occur:
#!/usr/bin/env bash
set -euo pipefail
readonly TEMP_DIR="$(mktemp -d)"
cleanup() {
local exit_code=$?
rm -rf "$TEMP_DIR"
if [[ $exit_code -ne 0 ]]; then
echo "ERROR: Script failed with exit code $exit_code" >&2
fi
exit "$exit_code"
}
trap cleanup EXIT INT TERM
# Your script logic here
# Cleanup runs automatically on exit, Ctrl+C, or termination
Create a standardized error handling function:
error_exit() {
echo "ERROR: $1" >&2
exit "${2:-1}"
}
# Usage
if [[ ! -f "$CONFIG_FILE" ]]; then
error_exit "Configuration file not found: $CONFIG_FILE" 2
fi
Use meaningful exit codes: 0 for success, 1 for general errors, 2 for misuse, and other values for specific error conditions. Document your exit codes in the script header.
Functions and Code Reusability
Well-structured functions make scripts maintainable and testable. Functions should do one thing well and have clear interfaces:
# Function library pattern
validate_email() {
local email="$1"
local email_regex='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if [[ $email =~ $email_regex ]]; then
return 0
else
return 1
fi
}
send_notification() {
local recipient="$1"
local message="$2"
if ! validate_email "$recipient"; then
echo "ERROR: Invalid email address: $recipient" >&2
return 1
fi
# Send notification
echo "$message" | mail -s "Notification" "$recipient"
}
# Usage
if send_notification "admin@example.com" "Backup completed"; then
echo "Notification sent successfully"
else
echo "Failed to send notification" >&2
fi
For functions that need to return data (not just success/failure), use command substitution or pass variable names:
# Return via stdout (preferred for simple cases)
get_timestamp() {
date +%Y%m%d_%H%M%S
}
timestamp="$(get_timestamp)"
# Return via nameref (for complex data)
get_system_info() {
local -n result=$1
result[hostname]="$(hostname)"
result[kernel]="$(uname -r)"
result[uptime]="$(uptime -p)"
}
declare -A sys_info
get_system_info sys_info
echo "Host: ${sys_info[hostname]}"
Input Validation and Argument Parsing
Use getopts for parsing command-line arguments. It handles edge cases correctly and provides standard Unix-style option parsing:
usage() {
cat << EOF
Usage: $SCRIPT_NAME [OPTIONS]
OPTIONS:
-d DIR Directory to process (required)
-o FILE Output file (default: stdout)
-v Verbose mode
-h Show this help message
EXAMPLES:
$SCRIPT_NAME -d /var/log -o report.txt
$SCRIPT_NAME -d /home/user -v
EOF
exit 0
}
# Parse arguments
VERBOSE=false
OUTPUT_FILE=""
PROCESS_DIR=""
while getopts "d:o:vh" opt; do
case $opt in
d) PROCESS_DIR="$OPTARG" ;;
o) OUTPUT_FILE="$OPTARG" ;;
v) VERBOSE=true ;;
h) usage ;;
\?) error_exit "Invalid option: -$OPTARG" 2 ;;
:) error_exit "Option -$OPTARG requires an argument" 2 ;;
esac
done
# Validate required arguments
if [[ -z "$PROCESS_DIR" ]]; then
error_exit "Directory argument (-d) is required" 2
fi
if [[ ! -d "$PROCESS_DIR" ]]; then
error_exit "Directory does not exist: $PROCESS_DIR" 1
fi
Always validate input before using it. Check file existence, directory permissions, and format constraints.
Security Considerations
Command injection is a serious risk in shell scripts. Never pass unsanitized user input directly to commands:
# DANGEROUS - command injection vulnerability
user_input="$1"
eval "ls $user_input" # NEVER use eval with user input
# SAFER - but still risky with unquoted variable
ls $user_input
# SAFE - properly quoted
ls -- "$user_input"
# SAFEST - validate input first
if [[ "$user_input" =~ ^[a-zA-Z0-9/_-]+$ ]]; then
ls -- "$user_input"
else
error_exit "Invalid directory name"
fi
Use mktemp for temporary files to prevent race conditions and predictable file names:
# WRONG - predictable filename
temp_file="/tmp/myapp_$$"
# CORRECT
temp_file="$(mktemp)"
trap 'rm -f "$temp_file"' EXIT
# For temporary directories
temp_dir="$(mktemp -d)"
trap 'rm -rf "$temp_dir"' EXIT
Run scripts with minimum necessary privileges. If you need elevated permissions for specific operations, use sudo for just those commands rather than running the entire script as root.
Testing and Debugging
Use shellcheck to catch common errors before running scripts. Install it and integrate it into your development workflow:
# Install shellcheck
sudo apt-get install shellcheck # Debian/Ubuntu
brew install shellcheck # macOS
# Check a script
shellcheck myscript.sh
For debugging, create a logging function that respects verbosity settings:
# Debug levels: ERROR, WARN, INFO, DEBUG
LOG_LEVEL="${LOG_LEVEL:-INFO}"
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
case "$LOG_LEVEL" in
DEBUG) [[ "$level" =~ ^(DEBUG|INFO|WARN|ERROR)$ ]] && echo "[$timestamp] $level: $message" >&2 ;;
INFO) [[ "$level" =~ ^(INFO|WARN|ERROR)$ ]] && echo "[$timestamp] $level: $message" >&2 ;;
WARN) [[ "$level" =~ ^(WARN|ERROR)$ ]] && echo "[$timestamp] $level: $message" >&2 ;;
ERROR) [[ "$level" == "ERROR" ]] && echo "[$timestamp] $level: $message" >&2 ;;
esac
}
# Usage
log DEBUG "Processing file: $filename"
log INFO "Backup completed successfully"
log WARN "Disk space running low"
log ERROR "Failed to connect to database"
Enable debug mode temporarily with set -x, or make it conditional:
# Debug mode via environment variable
if [[ "${DEBUG:-}" == "true" ]]; then
set -x
fi
These practices transform shell scripts from fragile hacks into reliable automation tools. Start with the strict error handling modes, quote your variables religiously, and invest in proper error handling. Your future self—and your teammates—will thank you when scripts work correctly in production instead of failing mysteriously at 3 AM.