Bash
Language Overview¶
Bash (Bourne Again SHell) is a Unix shell and command language used for automation, system administration, and DevOps workflows. While powerful for system tasks, it has limitations that make higher-level languages preferable for complex logic.
Key Characteristics¶
- Paradigm: Procedural scripting language
- Type System: Untyped (strings by default)
- Execution: Interpreted by shell
- POSIX Compliance: Target POSIX sh for maximum portability
- Use Case: System automation, CI/CD pipelines, simple glue scripts
When to Use Bash¶
✅ Good Use Cases:
- Simple automation scripts (< 200 lines)
- System administration tasks
- CI/CD pipeline steps
- Git hooks
- Docker entrypoint scripts
- Environment setup scripts
- File manipulation and system commands
❌ Avoid Bash For:
- Complex business logic
- Data processing and transformation
- API clients
- Scripts requiring JSON/YAML parsing
- Code requiring testing frameworks
- Scripts > 200 lines
Use Python, Go, or TypeScript instead when:
- You need data structures (maps, arrays, objects)
- JSON/YAML processing is required
- Complex error handling needed
- Unit testing is important
- Cross-platform compatibility matters
Quick Reference¶
| Category | Convention | Example | Notes |
|---|---|---|---|
| Naming | |||
| Variables | lowercase or snake_case |
user_count, max_retries |
Local variables lowercase |
| Constants | UPPER_SNAKE_CASE |
MAX_RETRIES, API_URL |
Readonly global variables |
| Functions | lowercase or snake_case |
get_user(), validate_input() |
Descriptive function names |
| Environment Vars | UPPER_SNAKE_CASE |
PATH, HOME, MYAPP_CONFIG |
Exported variables |
| Files | |||
| Scripts | kebab-case.sh |
deploy-app.sh, backup.sh |
Lowercase with .sh extension |
| Executable | No extension | deploy-app |
If in PATH, omit .sh |
| Shebang | |||
| POSIX | #!/bin/sh |
#!/bin/sh |
Maximum portability |
| Bash-specific | #!/usr/bin/env bash |
#!/usr/bin/env bash |
When Bash features needed |
| Formatting | |||
| Indentation | 2 spaces | if [ "$x" = "y" ]; then |
Never tabs |
| Line Length | 80 characters | # Keep lines short |
Maximum readability |
| Quoting | |||
| Variables | Always quote | "$variable" |
Prevent word splitting |
| Arrays | Quote expansion | "${array[@]}" |
Preserve elements |
| Conditionals | |||
| POSIX Test | [ condition ] |
if [ "$x" = "y" ]; then |
Single brackets |
| Bash Test | [[ condition ]] |
if [[ $x == y ]]; then |
Double brackets (non-POSIX) |
| Error Handling | |||
| Exit on Error | set -e |
set -euo pipefail |
Fail fast on errors |
| Undefined Vars | set -u |
set -euo pipefail |
Error on undefined variables |
| Pipe Failures | set -o pipefail |
set -euo pipefail |
Catch pipe failures |
| Functions | |||
| Declaration | POSIX style | func_name() { ... } |
No function keyword |
| Return | Exit code | return 1 |
0 = success, non-zero = failure |
POSIX Compliance¶
Write POSIX-compliant scripts for maximum portability across systems.
#!/bin/sh
## Good - POSIX compliant shebang
#!/usr/bin/env bash
## Acceptable - When bash-specific features are needed
## Document bash requirement in README
Bash-only Features to Avoid¶
## Bad - Bash-specific array syntax
declare -a my_array=("item1" "item2")
## Bad - Bash-specific [[ ]] test
if [[ "$var" == "value" ]]; then
echo "match"
fi
## Good - POSIX compliant [ ] test
if [ "$var" = "value" ]; then
echo "match"
fi
## Bad - Bash process substitution
diff <(command1) <(command2)
## Good - Use temporary files
command1 > /tmp/file1
command2 > /tmp/file2
diff /tmp/file1 /tmp/file2
Script Header and Metadata¶
Every script must start with a header including metadata and error handling:
#!/bin/sh
"""
@module deploy_application
@description Deploys application to production environment
@dependencies curl, jq, docker
@version 1.2.0
@author Tyler Dukes
@last_updated 2025-10-28
"""
## Strict error handling
set -o errexit # Exit on error
set -o nounset # Exit on undefined variable
set -o pipefail # Catch errors in pipelines
## Script constants
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly LOG_FILE="/var/log/${SCRIPT_NAME}.log"
## Color codes for output
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly NC='\033[0m' # No Color
Set Options Explained¶
set -o errexit # Exit immediately if a command exits with non-zero status
set -o nounset # Treat unset variables as errors
set -o pipefail # Return exit status of last failed command in pipeline
## Alternative short form
set -euo pipefail
Function Definitions¶
Use functions for reusable code blocks:
## Function definition - no 'function' keyword for POSIX compliance
log_info() {
local message="$1"
echo "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $message" >&2
}
log_error() {
local message="$1"
echo "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $message" >&2
}
log_warning() {
local message="$1"
echo "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $message" >&2
}
## Function with return value
check_command_exists() {
local cmd="$1"
if command -v "$cmd" >/dev/null 2>&1; then
return 0
else
return 1
fi
}
## Function with multiple parameters
deploy_service() {
local service_name="$1"
local environment="$2"
local version="${3:-latest}" # Default to 'latest'
log_info "Deploying $service_name to $environment (version: $version)"
# Deployment logic here
if docker pull "$service_name:$version"; then
log_info "Successfully pulled $service_name:$version"
return 0
else
log_error "Failed to pull $service_name:$version"
return 1
fi
}
Argument Parsing¶
Simple Argument Parsing¶
show_help() {
cat << EOF
Usage: $SCRIPT_NAME [OPTIONS] <environment>
Deploy application to specified environment
ARGUMENTS:
environment Target environment (dev|staging|prod)
OPTIONS:
-h, --help Show this help message
-v, --version Show script version
-d, --dry-run Run in dry-run mode
-f, --force Force deployment without confirmation
EXAMPLES:
$SCRIPT_NAME staging
$SCRIPT_NAME --dry-run prod
$SCRIPT_NAME -f staging
EOF
}
## Parse command-line arguments
parse_arguments() {
DRY_RUN=false
FORCE=false
ENVIRONMENT=""
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
show_help
exit 0
;;
-v|--version)
echo "$SCRIPT_NAME version 1.2.0"
exit 0
;;
-d|--dry-run)
DRY_RUN=true
shift
;;
-f|--force)
FORCE=true
shift
;;
-*)
log_error "Unknown option: $1"
show_help
exit 1
;;
*)
ENVIRONMENT="$1"
shift
;;
esac
done
# Validate required arguments
if [ -z "$ENVIRONMENT" ]; then
log_error "Environment argument is required"
show_help
exit 1
fi
# Validate environment value
case "$ENVIRONMENT" in
dev|staging|prod)
;;
*)
log_error "Invalid environment: $ENVIRONMENT (must be dev, staging, or prod)"
exit 1
;;
esac
}
Error Handling¶
Trap Signals for Cleanup¶
## Cleanup function
cleanup() {
local exit_code=$?
log_info "Cleaning up temporary files..."
rm -f "$TEMP_FILE"
rm -rf "$TEMP_DIR"
if [ $exit_code -ne 0 ]; then
log_error "Script failed with exit code $exit_code"
else
log_info "Script completed successfully"
fi
exit $exit_code
}
## Register cleanup trap
trap cleanup EXIT INT TERM
## Create temporary files
TEMP_FILE=$(mktemp)
TEMP_DIR=$(mktemp -d)
Error Handling Patterns¶
## Check command success
if ! check_command_exists "docker"; then
log_error "docker is not installed"
exit 1
fi
## Capture command output and check status
if output=$(docker ps 2>&1); then
log_info "Docker is running"
else
log_error "Docker command failed: $output"
exit 1
fi
## Conditional execution with error messages
docker pull "$IMAGE_NAME" || {
log_error "Failed to pull Docker image $IMAGE_NAME"
exit 1
}
## Use subshell to prevent exit on error
if (set -e; command1 && command2 && command3); then
log_info "All commands succeeded"
else
log_error "One or more commands failed"
fi
Temporary File Handling¶
Always use mktemp for temporary files and ensure cleanup:
## Create temporary file
TEMP_FILE=$(mktemp) || {
log_error "Failed to create temporary file"
exit 1
}
## Create temporary directory
TEMP_DIR=$(mktemp -d) || {
log_error "Failed to create temporary directory"
exit 1
}
## Ensure cleanup on exit
trap 'rm -f "$TEMP_FILE"; rm -rf "$TEMP_DIR"' EXIT
## Use temporary file
echo "data" > "$TEMP_FILE"
process_file "$TEMP_FILE"
String Manipulation¶
## Variable assignment
name="John Doe"
## String length
length=${#name}
## Substring extraction (not POSIX - use cut/awk instead for portability)
## ${variable:offset:length} is bash-specific
## POSIX-compliant substring with cut
first_name=$(echo "$name" | cut -d' ' -f1)
## String replacement (use sed for POSIX)
## ${variable/pattern/replacement} is bash-specific
## POSIX-compliant replacement
new_name=$(echo "$name" | sed 's/John/Jane/')
## Case conversion (use tr for POSIX)
upper_name=$(echo "$name" | tr '[:lower:]' '[:upper:]')
lower_name=$(echo "$name" | tr '[:upper:]' '[:lower:]')
## String concatenation
full_path="${directory}/${filename}"
## Default values
database_host="${DB_HOST:-localhost}"
database_port="${DB_PORT:-5432}"
Conditional Statements¶
## Basic if statement
if [ "$ENVIRONMENT" = "prod" ]; then
log_warning "Deploying to production"
fi
## If-else
if [ -f "$config_file" ]; then
log_info "Config file found: $config_file"
else
log_error "Config file not found: $config_file"
exit 1
fi
## If-elif-else
if [ "$status_code" -eq 200 ]; then
log_info "Request successful"
elif [ "$status_code" -eq 404 ]; then
log_error "Resource not found"
elif [ "$status_code" -ge 500 ]; then
log_error "Server error"
else
log_warning "Unexpected status code: $status_code"
fi
## Test operators
[ -f "$file" ] # File exists and is regular file
[ -d "$dir" ] # Directory exists
[ -z "$var" ] # String is empty
[ -n "$var" ] # String is not empty
[ "$a" = "$b" ] # Strings are equal
[ "$a" != "$b" ] # Strings are not equal
[ "$a" -eq "$b" ] # Numbers are equal
[ "$a" -ne "$b" ] # Numbers are not equal
[ "$a" -lt "$b" ] # a less than b
[ "$a" -le "$b" ] # a less than or equal to b
[ "$a" -gt "$b" ] # a greater than b
[ "$a" -ge "$b" ] # a greater than or equal to b
## Logical operators
[ -f "$file" ] && [ -r "$file" ] # AND
[ -f "$file" ] || [ -d "$dir" ] # OR
[ ! -f "$file" ] # NOT
Loops¶
## For loop with list
for env in dev staging prod; do
log_info "Deploying to $env"
deploy_to_environment "$env"
done
## For loop with command output
for file in *.txt; do
if [ -f "$file" ]; then
process_file "$file"
fi
done
## For loop with range (use seq for POSIX)
for i in $(seq 1 5); do
echo "Iteration $i"
done
## While loop
count=0
while [ $count -lt 10 ]; do
log_info "Count: $count"
count=$((count + 1))
done
## Read file line by line
while IFS= read -r line; do
process_line "$line"
done < "$input_file"
## Until loop
until check_service_health; do
log_info "Waiting for service to be healthy..."
sleep 5
done
HERE Documents¶
## Basic HERE document
cat << EOF
This is a multi-line
text block that will
be printed as-is
EOF
## HERE document with variable expansion
cat << EOF
Environment: $ENVIRONMENT
Deployment time: $(date)
User: $USER
EOF
## HERE document without variable expansion (quoted delimiter)
cat << 'EOF'
This will not expand $VARIABLES
Use this for literal text
EOF
## HERE document to file
cat << EOF > config.yaml
---
environment: $ENVIRONMENT
database:
host: $DB_HOST
port: $DB_PORT
EOF
## HERE document to command
docker run -i myimage << EOF
command1
command2
command3
EOF
Command Substitution¶
## Modern command substitution (POSIX)
current_date=$(date '+%Y-%m-%d')
file_count=$(ls -1 | wc -l)
git_branch=$(git rev-parse --abbrev-ref HEAD)
## Nested command substitution
project_root=$(cd "$(dirname "$0")/.." && pwd)
## Capture command output and status
if output=$(docker ps 2>&1); then
log_info "Docker running with $(echo "$output" | wc -l) containers"
fi
Arrays (Use Carefully - Bash-specific)¶
For POSIX compliance, use whitespace-separated strings or multiple variables:
## POSIX-compliant approach - avoid arrays
environments="dev staging prod"
for env in $environments; do
echo "$env"
done
## If you MUST use arrays (bash-only), document the requirement
#!/bin/bash # Note: requires bash, not POSIX sh
## Bash array declaration
declare -a servers=("server1" "server2" "server3")
## Array iteration
for server in "${servers[@]}"; do
echo "Processing $server"
done
## Array length
count=${#servers[@]}
Common Pitfalls¶
Unquoted Variable Expansion¶
Issue: Unquoted variables undergo word splitting and glob expansion, causing failures with filenames containing spaces or special characters.
Example:
## Bad - Breaks with spaces in filename
file="my document.txt"
if [ -f $file ]; then # Expands to: [ -f my document.txt ]
cat $file # Error: cat: my: No such file or directory
fi
Solution: Always quote variable expansions unless you explicitly need word splitting.
## Good - Properly quoted
file="my document.txt"
if [ -f "$file" ]; then # Correctly: [ -f "my document.txt" ]
cat "$file"
fi
## Good - Array handling
files=("file1.txt" "file 2.txt" "file 3.txt")
for file in "${files[@]}"; do # Preserves each element
process "$file"
done
Key Points:
- Always quote variables:
"$var"not$var - Quote array expansions:
"${array[@]}" - Exceptions: When word splitting is intended (rare)
- Use ShellCheck to catch unquoted variables
Subshell Variable Scope¶
Issue: Variables set in subshells (pipes, command substitution) don't persist in the parent shell.
Example:
## Bad - count remains 0
count=0
echo "line1\nline2\nline3" | while read line; do
count=$((count + 1)) # Executes in subshell
done
echo "Lines: $count" # Prints: Lines: 0 (subshell variable lost)
Solution: Use process substitution, here-strings, or avoid pipes for variable assignment.
## Good - Process substitution (no subshell)
count=0
while read line; do
count=$((count + 1))
done < <(echo -e "line1\nline2\nline3")
echo "Lines: $count" # Prints: Lines: 3
## Good - Here-string
count=0
while read line; do
count=$((count + 1))
done <<< "$(cat file.txt)"
echo "Lines: $count"
## Good - Read from file directly
count=0
while IFS= read -r line; do
count=$((count + 1))
done < file.txt
echo "Lines: $count"
Key Points:
- Pipes create subshells; variables set inside don't persist
- Use
while ... done < <(command)to avoid subshells - Command substitution
$(...)runs in subshell - Export doesn't help with pipe subshells
Test Command Bracket Confusion¶
Issue: Mixing [ ] (POSIX test) and [[ ]] (Bash extension) causes portability issues and subtle bugs.
Example:
## Bad - Using == in POSIX test
if [ "$var" == "value" ]; then # Not POSIX compliant!
echo "match"
fi
## Bad - Pattern matching in [ ]
if [ "$file" == *.txt ]; then # Doesn't work as expected
echo "text file"
fi
Solution: Use [ ] with = for POSIX compliance, or use [[ ]] for Bash-specific features.
## Good - POSIX compliant
if [ "$var" = "value" ]; then # Single = for POSIX
echo "match"
fi
## Good - Bash pattern matching (requires [[ ]])
if [[ "$file" == *.txt ]]; then # Works with [[ ]]
echo "text file"
fi
## Good - Bash regex matching
if [[ "$email" =~ ^[a-z]+@[a-z]+\.[a-z]+$ ]]; then
echo "valid email"
fi
Key Points:
- Use
=not==in[ ]for portability - Pattern matching requires
[[ ]](Bash-only) - Regex matching only works with
[[ ]] [ ]is POSIX,[[ ]]is Bash-specific- Choose based on portability needs
Exit Code Confusion¶
Issue: Misunderstanding that 0 means success and non-zero means failure leads to inverted logic.
Example:
## Bad - Inverted logic
check_status() {
if systemctl is-active myapp; then
return 1 # Wrong! Returns failure on success
else
return 0 # Wrong! Returns success on failure
fi
}
if check_status; then # Triggers on 0 (wrong condition)
echo "Service is down"
fi
Solution: Return 0 for success, non-zero for failure. Test exit codes correctly.
## Good - Correct exit codes
check_status() {
if systemctl is-active myapp >/dev/null 2>&1; then
return 0 # Success
else
return 1 # Failure
fi
}
if check_status; then # if command succeeds (returns 0)
echo "Service is running"
else
echo "Service is down"
fi
## Good - Direct command testing
if systemctl is-active myapp >/dev/null 2>&1; then
echo "Running"
fi
Key Points:
- Exit code 0 = success, non-zero = failure
if commandsucceeds when command returns 0- Use
$?to capture last exit code - Functions return values via exit codes, not stdout
- Test exit codes:
if [ $? -eq 0 ]
Arithmetic Expansion Gotchas¶
Issue: Shell arithmetic doesn't support floating point, and leading zeros cause octal interpretation.
Example:
## Bad - Floating point (not supported)
result=$((10 / 3)) # Result: 3 (not 3.333...)
ratio=$((5.5 * 2)) # Error: invalid arithmetic operator
## Bad - Octal interpretation
number=08
result=$((number + 1)) # Error: invalid octal number
## Bad - Unquoted variables
value="10 + 5"
result=$((value)) # Evaluates to 15 (code injection risk!)
Solution: Use bc for floating point, strip leading zeros, validate input.
## Good - Use bc for floating point
result=$(echo "scale=2; 10 / 3" | bc) # 3.33
ratio=$(echo "scale=2; 5.5 * 2" | bc) # 11.0
## Good - Strip leading zeros
number=08
number=$((10#$number)) # Force base-10: 8
result=$((number + 1)) # 9
## Good - Validate numeric input
if [[ "$value" =~ ^[0-9]+$ ]]; then
result=$((value + 10))
else
echo "Error: Not a number"
fi
Key Points:
- Shell arithmetic is integer-only
- Use
bcfor floating point calculations - Leading zeros trigger octal (base-8) interpretation
- Use
10#$varto force base-10 - Validate numeric input before arithmetic
Set -e Trap Pitfalls¶
Issue: set -e doesn't exit on all errors, particularly in conditionals and pipes, creating false sense of safety.
Example:
#!/bin/bash
set -e # Exit on error
## Bad - These don't trigger exit despite errors
if false; then # Condition checked, no exit
echo "won't print"
fi
result=$(false) # Command substitution checked, no exit
echo "Still running: $result"
false && echo "won't print" # Left side of && doesn't exit
false || echo "will print" # Left side of || doesn't exit
Solution: Combine set -e with explicit error checking, use set -o pipefail, and understand its limitations.
#!/bin/bash
set -euo pipefail # Exit on error, undefined vars, pipe failures
## Good - Explicit error handling
if ! command1; then
echo "command1 failed" >&2
exit 1
fi
## Good - Check command substitution
if ! result=$(complex_command); then
echo "complex_command failed" >&2
exit 1
fi
## Good - Trap for cleanup
trap 'echo "Error on line $LINENO" >&2' ERR
Key Points:
set -edoesn't exit in:if,while,&&,||,!- Add
set -o pipefailto catch pipe failures - Use
set -uto catch undefined variables - Add traps for cleanup on error
- Don't rely solely on
set -e
Anti-Patterns¶
❌ Avoid: Unquoted Variables¶
## Bad - Word splitting and globbing issues
file=$1
if [ -f $file ]; then # Breaks with spaces in filename
cat $file
fi
## Good - Always quote variables
file="$1"
if [ -f "$file" ]; then
cat "$file"
fi
❌ Avoid: Using eval¶
## Bad - Security risk, arbitrary code execution
user_input="$1"
eval "$user_input"
## Good - Use explicit commands
case "$command" in
start) start_service ;;
stop) stop_service ;;
*) log_error "Unknown command" ;;
esac
❌ Avoid: Parsing ls Output¶
## Bad - Breaks with spaces, special characters
for file in $(ls *.txt); do
echo "$file"
done
## Good - Use glob patterns
for file in *.txt; do
if [ -f "$file" ]; then
echo "$file"
fi
done
❌ Avoid: Useless cat¶
## Bad - Unnecessary use of cat
cat file.txt | grep "pattern"
## Good - Direct input redirection
grep "pattern" file.txt
❌ Avoid: Test with ==¶
## Bad - Not POSIX compliant
if [ "$var" == "value" ]; then
echo "match"
fi
## Good - POSIX single =
if [ "$var" = "value" ]; then
echo "match"
fi
❌ Avoid: Ignoring Exit Codes¶
## Bad - No error checking
curl -o file.txt https://example.com/file.txt
process_file file.txt
## Good - Check exit codes
if curl -o file.txt https://example.com/file.txt; then
process_file file.txt
else
log_error "Failed to download file"
exit 1
fi
❌ Avoid: Using cd Without Checks¶
## Bad - cd might fail
cd /some/directory
rm -rf *
## Good - Check cd success
if ! cd /some/directory; then
log_error "Failed to change directory"
exit 1
fi
rm -rf *
## Better - Use subshell
(
cd /some/directory || exit 1
rm -rf *
)
Tool Configuration¶
shellcheck¶
.shellcheckrc:
## Disable specific warnings
disable=SC2034 # Unused variable
disable=SC2086 # Unquoted variable (if intentional)
## Enable all optional checks
enable=all
## Specify shell dialect
shell=sh
Run shellcheck:
shellcheck script.sh
shellcheck -x script.sh # Follow source files
shfmt¶
.editorconfig:
[*.sh]
indent_style = space
indent_size = 2
shell_variant = posix
Format scripts:
shfmt -w script.sh # Format in place
shfmt -i 2 -s script.sh # 2-space indent, simplify
Pre-commit Hook¶
.pre-commit-config.yaml:
repos:
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.9.0.5
hooks:
- id: shellcheck
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.7.0-1
hooks:
- id: shfmt
args: [-w, -i, "2", -s]
Complete Script Example¶
#!/bin/sh
"""
@module database_backup
@description Automated PostgreSQL database backup with rotation
@dependencies pg_dump, gzip, aws-cli
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-10-28
"""
## Strict error handling
set -o errexit
set -o nounset
set -o pipefail
## Constants
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly BACKUP_DIR="/var/backups/postgres"
readonly RETENTION_DAYS=7
readonly S3_BUCKET="s3://my-backups/postgres"
## Color codes
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly NC='\033[0m'
## Logging functions
log_info() {
echo "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" >&2
}
log_error() {
echo "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" >&2
}
log_warning() {
echo "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" >&2
}
## Cleanup function
cleanup() {
local exit_code=$?
log_info "Cleaning up temporary files..."
rm -f "$TEMP_FILE"
if [ $exit_code -ne 0 ]; then
log_error "Backup failed with exit code $exit_code"
fi
exit $exit_code
}
## Register cleanup trap
trap cleanup EXIT INT TERM
## Check prerequisites
check_prerequisites() {
local missing_deps=""
for cmd in pg_dump gzip aws; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing_deps="$missing_deps $cmd"
fi
done
if [ -n "$missing_deps" ]; then
log_error "Missing dependencies:$missing_deps"
return 1
fi
return 0
}
## Create backup
create_backup() {
local database="$1"
local timestamp=$(date '+%Y%m%d_%H%M%S')
local backup_file="${BACKUP_DIR}/${database}_${timestamp}.sql.gz"
log_info "Creating backup of database: $database"
# Create backup directory if needed
mkdir -p "$BACKUP_DIR"
# Create backup
if pg_dump "$database" | gzip > "$backup_file"; then
log_info "Backup created: $backup_file"
echo "$backup_file"
return 0
else
log_error "Failed to create backup"
return 1
fi
}
## Upload to S3
upload_to_s3() {
local backup_file="$1"
local s3_path="${S3_BUCKET}/$(basename "$backup_file")"
log_info "Uploading backup to S3: $s3_path"
if aws s3 cp "$backup_file" "$s3_path"; then
log_info "Backup uploaded successfully"
return 0
else
log_error "Failed to upload backup to S3"
return 1
fi
}
## Rotate old backups
rotate_backups() {
log_info "Rotating backups older than $RETENTION_DAYS days"
find "$BACKUP_DIR" -name "*.sql.gz" -type f -mtime +$RETENTION_DAYS -delete
local deleted_count=$(find "$BACKUP_DIR" -name "*.sql.gz" -type f -mtime +$RETENTION_DAYS | wc -l)
log_info "Deleted $deleted_count old backup(s)"
}
## Main function
main() {
local database="${1:-myapp_production}"
log_info "Starting database backup process"
# Check prerequisites
if ! check_prerequisites; then
exit 1
fi
# Create temporary file
TEMP_FILE=$(mktemp)
# Create backup
if backup_file=$(create_backup "$database"); then
log_info "Backup created successfully"
else
exit 1
fi
# Upload to S3
if upload_to_s3 "$backup_file"; then
log_info "Upload successful"
else
log_warning "Upload failed, backup kept locally"
fi
# Rotate old backups
rotate_backups
log_info "Backup process completed successfully"
}
## Run main function with arguments
main "$@"
Testing¶
Testing Framework: BATS¶
Use BATS (Bash Automated Testing System) for testing shell scripts:
## Install BATS
git clone https://github.com/bats-core/bats-core.git
cd bats-core
./install.sh /usr/local
## Or via package manager
brew install bats-core # macOS
apt-get install bats # Debian/Ubuntu
Test Structure¶
Organize tests in a tests/ directory:
project/
├── scripts/
│ └── deploy.sh
├── tests/
│ ├── test_helper.bash
│ ├── deploy.bats
│ └── fixtures/
│ └── sample_config.yaml
└── .bats-version
BATS Test Example¶
## tests/deploy.bats
#!/usr/bin/env bats
# Load test helpers
load test_helper
setup() {
# Run before each test
export TEST_DIR="$(mktemp -d)"
export PATH="$BATS_TEST_DIRNAME/../scripts:$PATH"
}
teardown() {
# Run after each test
rm -rf "$TEST_DIR"
}
@test "deploy script exists and is executable" {
run which deploy.sh
[ "$status" -eq 0 ]
[ -x "$(which deploy.sh)" ]
}
@test "deploy fails without required environment variable" {
run deploy.sh staging
[ "$status" -eq 1 ]
[[ "$output" =~ "DB_HOST not set" ]]
}
@test "deploy succeeds with valid configuration" {
export DB_HOST="localhost"
export DB_PORT="5432"
run deploy.sh staging
[ "$status" -eq 0 ]
[[ "$output" =~ "Deployment successful" ]]
}
@test "validate_path rejects path traversal" {
source ../scripts/deploy.sh
run validate_path "../../../etc/passwd"
[ "$status" -eq 1 ]
[[ "$output" =~ "Path traversal detected" ]]
}
@test "log functions write to stderr" {
source ../scripts/deploy.sh
run log_info "test message"
[ "$status" -eq 0 ]
# BATS captures stderr in $output when using run
[[ "$output" =~ "test message" ]]
}
Test Helper Functions¶
## tests/test_helper.bash
# Common test setup
export FIXTURES="$BATS_TEST_DIRNAME/fixtures"
# Helper to check command exists
assert_command_exists() {
local cmd="$1"
command -v "$cmd" >/dev/null 2>&1 || {
echo "Required command not found: $cmd"
return 1
}
}
# Helper to assert file contains string
assert_file_contains() {
local file="$1"
local pattern="$2"
grep -q "$pattern" "$file" || {
echo "File $file does not contain: $pattern"
return 1
}
}
# Helper to mock external commands
mock_command() {
local cmd_name="$1"
local mock_script="$2"
# Create mock in temporary bin directory
mkdir -p "$TEST_DIR/bin"
cat > "$TEST_DIR/bin/$cmd_name" << EOF
#!/bin/sh
$mock_script
EOF
chmod +x "$TEST_DIR/bin/$cmd_name"
export PATH="$TEST_DIR/bin:$PATH"
}
Testing Script Functions¶
## Example: Testing individual functions
## tests/functions.bats
#!/usr/bin/env bats
load test_helper
setup() {
# Source the script to test individual functions
source "$BATS_TEST_DIRNAME/../scripts/backup.sh"
}
@test "check_prerequisites detects missing commands" {
# Mock command to return failure
mock_command "pg_dump" "exit 1"
run check_prerequisites
[ "$status" -eq 1 ]
[[ "$output" =~ "Missing dependencies" ]]
}
@test "create_backup generates valid filename" {
export BACKUP_DIR="$TEST_DIR/backups"
run create_backup "testdb"
[ "$status" -eq 0 ]
# Check filename format: database_YYYYMMDD_HHMMSS.sql.gz
[[ "$output" =~ testdb_[0-9]{8}_[0-9]{6}.sql.gz ]]
}
@test "rotate_backups removes old files" {
export BACKUP_DIR="$TEST_DIR/backups"
mkdir -p "$BACKUP_DIR"
# Create old backup file (8 days old)
old_backup="$BACKUP_DIR/old_backup.sql.gz"
touch "$old_backup"
touch -t "$(date -d '8 days ago' +%Y%m%d%H%M)" "$old_backup"
# Create recent backup
recent_backup="$BACKUP_DIR/recent_backup.sql.gz"
touch "$recent_backup"
run rotate_backups
[ "$status" -eq 0 ]
# Old backup should be deleted
[ ! -f "$old_backup" ]
# Recent backup should remain
[ -f "$recent_backup" ]
}
Integration Testing¶
## tests/integration.bats
#!/usr/bin/env bats
load test_helper
setup() {
export TEST_DIR="$(mktemp -d)"
export PATH="$BATS_TEST_DIRNAME/../scripts:$PATH"
# Setup test environment
export DB_HOST="localhost"
export DB_PORT="5432"
export ENVIRONMENT="test"
}
teardown() {
rm -rf "$TEST_DIR"
}
@test "full deployment workflow" {
# Mock external dependencies
mock_command "docker" "echo 'Image pulled successfully'"
mock_command "kubectl" "echo 'Deployment updated'"
run deploy.sh test
[ "$status" -eq 0 ]
# Verify deployment steps occurred
[[ "$output" =~ "Checking prerequisites" ]]
[[ "$output" =~ "Pulling Docker image" ]]
[[ "$output" =~ "Updating Kubernetes deployment" ]]
[[ "$output" =~ "Deployment successful" ]]
}
Running Tests¶
## Run all tests
bats tests/
## Run specific test file
bats tests/deploy.bats
## Run tests with verbose output
bats --verbose tests/
## Run tests with tap output (for CI/CD)
bats --tap tests/
## Run tests recursively
bats --recursive tests/
## Run tests with timing
bats --timing tests/
ShellCheck Integration¶
Combine BATS with ShellCheck for comprehensive testing:
## tests/shellcheck.bats
#!/usr/bin/env bats
@test "all scripts pass shellcheck" {
for script in scripts/*.sh; do
run shellcheck "$script"
[ "$status" -eq 0 ]
done
}
@test "scripts follow POSIX standards" {
for script in scripts/*.sh; do
run shellcheck --shell=sh "$script"
[ "$status" -eq 0 ]
done
}
CI/CD Integration¶
## .github/workflows/test.yml
name: Test Scripts
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install BATS
run: |
sudo apt-get update
sudo apt-get install -y bats
- name: Install ShellCheck
run: sudo apt-get install -y shellcheck
- name: Run BATS tests
run: bats --recursive --tap tests/
- name: Run ShellCheck
run: |
find scripts -name "*.sh" -exec shellcheck {} +
- name: Check script formatting
run: |
shfmt -d -i 2 -s scripts/
Coverage and Quality Metrics¶
While Bash doesn't have native coverage tools, you can track test quality:
## tests/coverage.sh
#!/bin/sh
# Count functions in scripts
total_functions=$(grep -r "^[a-z_]*() {" scripts/ | wc -l)
# Count tested functions
tested_functions=$(grep -r "@test.*function" tests/ | wc -l)
# Calculate coverage percentage
coverage=$((tested_functions * 100 / total_functions))
echo "Function Test Coverage: ${coverage}%"
echo "Total Functions: $total_functions"
echo "Tested Functions: $tested_functions"
if [ "$coverage" -lt 80 ]; then
echo "ERROR: Coverage below 80% threshold"
exit 1
fi
Security Best Practices¶
Command Injection Prevention¶
Always quote variables and validate input to prevent command injection attacks.
## Bad - Vulnerable to command injection
user_input="$1"
eval "ls $user_input" # NEVER use eval with user input!
files=$(find . -name $user_input) # Unquoted variable vulnerable
## Good - Properly quoted and validated
user_input="$1"
# Validate input matches expected pattern
if ! printf '%s\n' "$user_input" | grep -Eq '^[a-zA-Z0-9_-]+$'; then
echo "Error: Invalid input format" >&2
exit 1
fi
# Always quote variables
files=$(find . -name "$user_input")
## Better - Use arrays for complex commands
search_paths=("/var/log" "/var/tmp")
find "${search_paths[@]}" -name "*.log"
Input Validation and Sanitization¶
## Validate file paths
validate_path() {
local path="$1"
# Check for path traversal attempts
case "$path" in
*..*)
echo "Error: Path traversal detected" >&2
return 1
;;
/*)
echo "Error: Absolute paths not allowed" >&2
return 1
;;
esac
# Check path exists and is within allowed directory
if [ ! -e "$path" ]; then
echo "Error: Path does not exist" >&2
return 1
fi
return 0
}
## Validate numeric input
validate_number() {
local input="$1"
case "$input" in
''|*[!0-9]*)
echo "Error: Not a valid number" >&2
return 1
;;
esac
return 0
}
## Example usage
user_file="$1"
if validate_path "$user_file"; then
cat "$user_file"
fi
Secure Credential Management¶
## Bad - Hardcoded credentials (NEVER DO THIS)
DB_PASSWORD="supersecret123"
API_KEY="sk_live_abc123"
aws_access_key="AKIAIOSFODNN7EXAMPLE"
## Good - Use environment variables
DB_PASSWORD="${DB_PASSWORD:?Database password not set}"
API_KEY="${API_KEY:?API key not set}"
## Good - Read from secure file with restricted permissions
read_secret() {
local secret_file="$1"
# Verify file permissions (should be 600 or 400)
if [ -f "$secret_file" ]; then
perms=$(stat -c '%a' "$secret_file" 2>/dev/null || stat -f '%A' "$secret_file" 2>/dev/null)
if [ "$perms" != "600" ] && [ "$perms" != "400" ]; then
echo "Error: Secret file has insecure permissions: $perms" >&2
return 1
fi
cat "$secret_file"
else
echo "Error: Secret file not found" >&2
return 1
fi
}
## Use secrets
db_password=$(read_secret "/run/secrets/db_password")
Secure Temporary File Handling¶
## Bad - Predictable temp file names (race condition vulnerability)
tmp_file="/tmp/myapp.txt"
echo "data" > "$tmp_file" # Attacker can predict this!
## Good - Use mktemp for secure temporary files
tmp_file=$(mktemp) || exit 1
trap 'rm -f "$tmp_file"' EXIT INT TERM
echo "sensitive data" > "$tmp_file"
chmod 600 "$tmp_file" # Restrict permissions
## Process temp file
# ...
## Cleanup handled by trap
## Good - Temporary directory
tmp_dir=$(mktemp -d) || exit 1
trap 'rm -rf "$tmp_dir"' EXIT INT TERM
# Work in temporary directory
cd "$tmp_dir" || exit 1
Safe File Operations¶
## Prevent symlink attacks
safe_write() {
local target_file="$1"
local content="$2"
# Check if file is a symlink
if [ -L "$target_file" ]; then
echo "Error: Will not write to symlink" >&2
return 1
fi
# Create file with restrictive permissions
(umask 077 && printf '%s\n' "$content" > "$target_file")
}
## Safely delete files
safe_delete() {
local file="$1"
# Verify file exists and is a regular file
if [ ! -f "$file" ]; then
echo "Error: Not a regular file" >&2
return 1
fi
# Check we're not deleting system files
case "$file" in
/bin/*|/sbin/*|/usr/bin/*|/usr/sbin/*|/etc/*)
echo "Error: Refusing to delete system file" >&2
return 1
;;
esac
rm -f "$file"
}
Secure Downloads¶
## Download files securely with verification
secure_download() {
local url="$1"
local output="$2"
local expected_checksum="$3"
# Download with timeout and fail on error
if ! curl --fail --silent --show-error --max-time 300 \
--location "$url" --output "$output"; then
echo "Error: Download failed" >&2
return 1
fi
# Verify checksum if provided
if [ -n "$expected_checksum" ]; then
actual_checksum=$(sha256sum "$output" | cut -d' ' -f1)
if [ "$actual_checksum" != "$expected_checksum" ]; then
echo "Error: Checksum mismatch" >&2
echo "Expected: $expected_checksum" >&2
echo "Got: $actual_checksum" >&2
rm -f "$output"
return 1
fi
fi
return 0
}
## Example usage
url="https://example.com/package.tar.gz"
checksum="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
secure_download "$url" "package.tar.gz" "$checksum"
Logging Sensitive Data¶
## Bad - Logging passwords and secrets
echo "Connecting to database with password: $DB_PASSWORD" # NEVER!
curl -v "https://api.example.com?api_key=$API_KEY" # Logged in curl output!
## Good - Redact sensitive information
log_safe() {
local message="$1"
# Redact potential secrets (credit cards, API keys, tokens)
echo "$message" | sed -E \
-e 's/password[=:][^ ]*/password=***REDACTED***/gi' \
-e 's/api[_-]?key[=:][^ ]*/api_key=***REDACTED***/gi' \
-e 's/token[=:][^ ]*/token=***REDACTED***/gi' \
-e 's/[0-9]{13,19}/****-****-****-****/g' # Credit card numbers
}
## Use for logging
message="Connecting to API with api_key=sk_live_abc123"
log_safe "$message" # Outputs: Connecting to API with api_key=***REDACTED***
Process Isolation¶
## Run untrusted commands with limited permissions
run_sandboxed() {
local command="$1"
# Create restricted user if needed
if ! id sandbox-user >/dev/null 2>&1; then
useradd -r -s /bin/false sandbox-user
fi
# Run command as limited user with timeout
sudo -u sandbox-user timeout 30s sh -c "$command"
}
## Limit resource usage
ulimit -t 30 # CPU time limit (seconds)
ulimit -v 1000000 # Virtual memory limit (KB)
ulimit -f 10000 # File size limit (blocks)
When to Use Higher-Level Languages¶
Replace Bash with Python, Go, or TypeScript when you need:
Use Python When¶
- Parsing JSON/YAML configuration files
- Making HTTP API calls
- Complex data transformations
- String manipulation beyond basic patterns
- Scripts requiring unit tests
- Cross-platform compatibility
Use Go When¶
- Building compiled binaries for distribution
- Performance is critical
- Strong typing needed
- Concurrent operations required
- Building CLI tools with subcommands
Use TypeScript/Node.js When¶
- Integrating with JavaScript ecosystems
- Processing JSON extensively
- Building CLI tools with rich UX
- Async I/O operations
Example: When NOT to Use Bash¶
## Bad - Complex JSON parsing in Bash
## This should be Python/Go/TypeScript
response=$(curl -s https://api.example.com/users)
## Trying to parse JSON with grep/sed is fragile and error-prone
user_id=$(echo "$response" | grep -o '"id":[0-9]*' | cut -d: -f2)
## Good - Use Python for JSON APIs
import requests
response = requests.get('https://api.example.com/users')
data = response.json()
user_id = data['id']
References¶
Official Documentation¶
Tools¶
- shellcheck - Shell script static analysis tool
- shfmt - Shell script formatter
- bats - Bash Automated Testing System
Best Practices¶
Status: Active