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 pipefail at 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 trap for 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-zero
  • set -u: Treat unset variables as errors
  • set -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:

  • UPPERCASE for environment variables and constants
  • lowercase for local variables and function parameters
  • Use readonly for values that shouldn’t change
  • Use local for 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.