Write clean, maintainable, and robust Bash scripts
Script Structure
#!/usr/bin/env bash
#
# Script: backup_manager.sh
# Description: Automated backup system for databases and files
# Author: Your Name
# Date: 2024-11-10
# Version: 1.0
#
# Usage: ./backup_manager.sh [options]
#
# Dependencies:
# - rsync
# - tar
# - gzip
set -euo pipefail # Exit on error, undefined variables, pipe failures
IFS=$'\n\t' # Safe Internal Field Separator
# Constants
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly BACKUP_DIR="/var/backups"
readonly LOG_FILE="/var/log/backup.log"
# Functions
log_info() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] INFO: $*" | tee -a "$LOG_FILE"
}
# Main logic
main() {
log_info "Backup started"
# Script logic here
}
main "$@"
💡 Key Elements:
Clear shebang with /usr/bin/env bash
Comprehensive header comments
Error handling with set -euo pipefail
Organized structure with functions
Main function for entry point
Error Handling
Use set Options
#!/bin/bash
# Exit immediately if a command fails
set -e
# Exit if undefined variable is used
set -u
# Pipe failures cause script to exit
set -o pipefail
# Combined (recommended)
set -euo pipefail
Trap Errors
✓ GOOD
#!/bin/bash
set -euo pipefail
# Cleanup function
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "Error occurred. Exit code: $exit_code" >&2
fi
# Cleanup temporary files
rm -f /tmp/script.$$.*
exit $exit_code
}
# Set trap
trap cleanup EXIT ERR
# Script logic here
Check Command Success
✓ Good
if ! command -v docker &> /dev/null; then
echo "Docker not found" >&2
exit 1
fi
if ! cp file.txt backup/; then
echo "Backup failed" >&2
exit 1
fi
✗ Bad
# Ignoring errors
cp file.txt backup/
# Not checking if command exists
docker ps
Variable Naming and Quoting
Always Quote Variables
✓ Good
filename="my file.txt"
if [ -f "$filename" ]; then
cat "$filename"
fi
for file in "$@"; do
process "$file"
done
✗ Bad
filename="my file.txt"
if [ -f $filename ]; then
cat $filename
fi
for file in $@; do
process $file
done
readonly API_URL="https://api.example.com"
readonly MAX_RETRIES=3
readonly BACKUP_DIR="/var/backups"
# These can't be modified later
# API_URL="other" # Would cause error
Function Best Practices
✓ GOOD
#!/bin/bash
# Function documentation
# Validates email address format
# Args:
# $1 - Email address to validate
# Returns:
# 0 if valid, 1 if invalid
validate_email() {
local email=$1
local regex='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if [[ ! $email =~ $regex ]]; then
echo "Invalid email format: $email" >&2
return 1
fi
return 0
}
# Process file with validation
# Args:
# $1 - Input file path
# $2 - Output directory (optional, defaults to current)
# Returns:
# 0 on success, 1 on failure
process_file() {
local input_file=$1
local output_dir=${2:-.}
# Validate inputs
if [ ! -f "$input_file" ]; then
echo "Error: File not found: $input_file" >&2
return 1
fi
if [ ! -d "$output_dir" ]; then
echo "Error: Directory not found: $output_dir" >&2
return 1
fi
# Process file
local output_file="$output_dir/$(basename "$input_file")"
cp "$input_file" "$output_file"
return 0
}
Input Validation
✓ GOOD
#!/bin/bash
# Validate argument count
if [ $# -lt 2 ]; then
echo "Usage: $0 " >&2
exit 1
fi
source_file=$1
dest_file=$2
# Validate file exists
if [ ! -f "$source_file" ]; then
echo "Error: Source file not found: $source_file" >&2
exit 1
fi
# Validate not overwriting
if [ -f "$dest_file" ]; then
read -p "Destination exists. Overwrite? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Operation cancelled"
exit 0
fi
fi
# Validate numeric input
read -p "Enter port number: " port
if ! [[ $port =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
echo "Error: Invalid port number" >&2
exit 1
fi
Safe File Operations
✓ Good: Use Temporary Files
# Create temp file safely
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT
# Process to temp first
process_data > "$temp_file"
# Move if successful
mv "$temp_file" final_file.txt
✗ Bad: Direct Overwrite
# Dangerous - loses data if fails
process_data > final_file.txt
Safe Directory Operations
✓ GOOD
# Change directory safely
cd "$directory" || {
echo "Failed to change to $directory" >&2
exit 1
}
# Or use subshell
(
cd "$directory" || exit 1
# Operations here don't affect parent shell
process_files
)
Logging and Output
✓ GOOD
#!/bin/bash
readonly LOG_FILE="/var/log/script.log"
# Logging functions
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
log_info() {
log "INFO: $*"
}
log_error() {
log "ERROR: $*" >&2
}
log_debug() {
if [ "${DEBUG:-0}" = "1" ]; then
log "DEBUG: $*"
fi
}
# Usage
log_info "Script started"
log_error "Connection failed"
log_debug "Variable value: $var"
# Separate stderr and stdout
{
echo "This goes to stdout"
echo "This goes to stderr" >&2
} | grep "stdout"
current_date=`date +%Y-%m-%d`
file_count=`ls -1 | wc -l`
# Hard to nest
backup_name="backup-`date +%Y%m%d`.tar.gz"
Testing and Conditionals
✓ Good: Use [[ ]]
# Pattern matching
if [[ $file == *.txt ]]; then
echo "Text file"
fi
# Regex
if [[ $email =~ ^[a-z]+@[a-z]+$ ]]; then
echo "Valid"
fi
# No word splitting issues
if [[ $var == "hello world" ]]; then
echo "Match"
fi
✗ Bad: Use [ ]
# No pattern matching
if [ $file == *.txt ]; then
echo "Text file"
fi
# No regex support
# Word splitting issues
if [ $var == "hello world" ]; then
echo "Match" # May fail
fi
Performance Tips
✓ GOOD
# Avoid unnecessary subprocess
# Good
count=0
for file in *.txt; do
((count++))
done
# Bad - spawns wc process
count=$(ls *.txt | wc -l)
# Use built-in commands
# Good
[[ -f "$file" ]]
# Bad - spawns test
test -f "$file"
# Minimize pipe usage
# Good
while IFS= read -r line; do
process "$line"
done < file.txt
# Bad - creates subshell
cat file.txt | while read line; do
process "$line"
done
Security Best Practices
✓ GOOD
# Never eval user input
# Bad: eval "$user_input"
# Use arrays for command building
command=(rsync -avz)
command+=("--exclude=*.tmp")
"${command[@]}" source/ dest/
# Validate and sanitize input
sanitize_filename() {
local filename=$1
# Remove dangerous characters
filename="${filename//[^a-zA-Z0-9._-]/}"
echo "$filename"
}
# Use full paths for commands in cron
/usr/bin/find /var/log -name "*.log" -delete
# Set restrictive permissions
touch sensitive_file
chmod 600 sensitive_file
# Don't store passwords in scripts
# Use environment variables or config files
# with restricted permissions
DB_PASSWORD="${DB_PASSWORD:-$(cat /etc/db_password)}"
Script Template
Here's a comprehensive template following all best practices:
TEMPLATE
#!/usr/bin/env bash
#
# Script: script_name.sh
# Description: Brief description of what script does
# Author: Your Name
# Date: 2024-11-10
# Version: 1.0
#
# Usage: ./script_name.sh [options] arg1 arg2
# -h, --help Show this help message
# -v, --verbose Enable verbose output
# -d, --debug Enable debug mode
#
# Examples:
# ./script_name.sh input.txt output.txt
# ./script_name.sh --verbose data/
#
set -euo pipefail
IFS=$'\n\t'
# Constants
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly VERSION="1.0"
# Variables
VERBOSE=0
DEBUG=0
# Functions
usage() {
cat << EOF
Usage: $SCRIPT_NAME [options] arg1 arg2
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
-d, --debug Enable debug mode
--version Show version
Examples:
$SCRIPT_NAME input.txt output.txt
$SCRIPT_NAME --verbose data/
EOF
exit 0
}
log_info() {
echo "[INFO] $*"
}
log_error() {
echo "[ERROR] $*" >&2
}
log_debug() {
if [ "$DEBUG" = "1" ]; then
echo "[DEBUG] $*"
fi
}
cleanup() {
local exit_code=$?
# Cleanup code here
exit $exit_code
}
trap cleanup EXIT ERR
parse_args() {
while [ $# -gt 0 ]; do
case $1 in
-h|--help)
usage
;;
-v|--verbose)
VERBOSE=1
shift
;;
-d|--debug)
DEBUG=1
shift
;;
--version)
echo "$SCRIPT_NAME version $VERSION"
exit 0
;;
-*)
log_error "Unknown option: $1"
usage
;;
*)
# Positional arguments
break
;;
esac
done
}
main() {
parse_args "$@"
log_info "Script started"
# Main logic here
log_info "Script completed"
}
main "$@"