Why Bash Scripting Still Matters
Despite the rise of Python, Go, and purpose-built automation tools, Bash scripting remains an indispensable skill for developers. It is the native language of Linux and macOS terminals, the default shell in most CI/CD environments, and the fastest way to glue together command-line tools into automated workflows. Whether you are provisioning servers, building deployment pipelines, or automating tedious local tasks, Bash gets the job done with zero dependencies.
This article goes beyond the basics. We will cover variables and parameter expansion, loops, functions with return values, error handling, and complete automation scripts you can adapt for your own projects.
Variables and Parameter Expansion
Bash variables are untyped strings, but parameter expansion gives you powerful string manipulation without reaching for external tools like sed or awk.
#!/bin/bash
# Variable assignment (no spaces around =)
project_name="my-web-app"
version="2.4.1"
deploy_env="${1:-staging}" # Default to staging if no argument
# String manipulation with parameter expansion
echo "Project: ${project_name}"
echo "Uppercase: ${project_name^^}"
echo "Replace hyphens: ${project_name//-/_}"
# Substring extraction
echo "Major version: ${version%%.*}" # 2
echo "Minor.patch: ${version#*.}" # 4.1
# Default values and error checking
db_host="${DB_HOST:?ERROR: DB_HOST environment variable is not set}"
db_port="${DB_PORT:-5432}"
log_level="${LOG_LEVEL:-info}"
# Arrays
environments=("development" "staging" "production")
echo "Deploy targets: ${environments[*]}"
echo "Number of environments: ${#environments[@]}"
# Associative arrays (Bash 4+)
declare -A service_ports
service_ports[web]=8080
service_ports[api]=3000
service_ports[worker]=9090
for service in "${!service_ports[@]}"; do
echo "$service runs on port ${service_ports[$service]}"
done
Loops and Iteration Patterns
Bash supports several loop constructs. The key is knowing which one fits each situation.
#!/bin/bash
# Iterate over files with a glob pattern
echo "=== JavaScript files ==="
for file in src/**/*.js; do
[[ -f "$file" ]] || continue
line_count=$(wc -l < "$file")
echo "$file: $line_count lines"
done
# C-style for loop
echo "=== Retry loop ==="
max_retries=5
for ((i = 1; i <= max_retries; i++)); do
echo "Attempt $i of $max_retries"
if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then
echo "Service is healthy!"
break
fi
sleep $((i * 2)) # Exponential-ish backoff
done
# While loop reading lines from a file
echo "=== Processing config ==="
while IFS='=' read -r key value; do
[[ "$key" =~ ^#.*$ ]] && continue # Skip comments
[[ -z "$key" ]] && continue # Skip empty lines
export "$key=$value"
echo "Set $key=$value"
done < ".env"
# Process substitution to loop over command output
echo "=== Large files ==="
while IFS= read -r file; do
size=$(du -h "$file" | cut -f1)
echo "$size $file"
done < <(find . -type f -size +10M 2>/dev/null)
Functions with Error Handling
Bash functions can return exit codes and output values via stdout. Combining functions with set -euo pipefail and trap creates robust scripts that fail gracefully.
#!/bin/bash
set -euo pipefail
# Trap for cleanup on exit
cleanup() {
local exit_code=$?
echo "Cleaning up temporary files..."
rm -rf "$TEMP_DIR"
if [[ $exit_code -ne 0 ]]; then
echo "Script failed with exit code $exit_code" >&2
fi
exit "$exit_code"
}
trap cleanup EXIT
TEMP_DIR=$(mktemp -d)
# Function that returns a value via stdout
get_latest_tag() {
local tag
tag=$(git describe --tags --abbrev=0 2>/dev/null) || {
echo "v0.0.0"
return
}
echo "$tag"
}
# Function with validation
deploy_service() {
local service="$1"
local environment="$2"
local version="${3:-latest}"
# Validate inputs
if [[ -z "$service" || -z "$environment" ]]; then
echo "Usage: deploy_service SERVICE ENVIRONMENT [VERSION]" >&2
return 1
fi
local valid_envs=("staging" "production")
if [[ ! " ${valid_envs[*]} " =~ " ${environment} " ]]; then
echo "ERROR: Invalid environment '$environment'" >&2
echo "Valid options: ${valid_envs[*]}" >&2
return 1
fi
echo "Deploying $service v$version to $environment..."
# Simulate deployment steps
echo " Pulling image: registry.example.com/$service:$version"
echo " Running health checks..."
echo " Switching traffic..."
echo " Deploy complete."
}
# Function with retry logic
with_retry() {
local max_attempts="$1"
shift
local cmd=("$@")
local attempt=1
until "${cmd[@]}"; do
if ((attempt >= max_attempts)); then
echo "FAILED after $max_attempts attempts: ${cmd[*]}" >&2
return 1
fi
echo "Attempt $attempt failed, retrying in ${attempt}s..."
sleep "$attempt"
((attempt++))
done
}
# Usage
current_tag=$(get_latest_tag)
echo "Current version: $current_tag"
deploy_service "api-server" "staging" "2.4.1"
with_retry 3 curl -sf http://localhost:8080/health
Real-World Automation: Project Setup Script
Here is a complete script that automates setting up a new development project. It creates a directory structure, initializes git, sets up a virtual environment, and configures pre-commit hooks.
#!/bin/bash
set -euo pipefail
PROJECT_NAME="${1:?Usage: $0 PROJECT_NAME [python|node]}"
PROJECT_TYPE="${2:-python}"
BASE_DIR="${HOME}/projects"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
error() { echo "[ERROR] $*" >&2; exit 1; }
# Validate project type
[[ "$PROJECT_TYPE" =~ ^(python|node)$ ]] || error "Unknown type: $PROJECT_TYPE"
PROJECT_DIR="${BASE_DIR}/${PROJECT_NAME}"
[[ -d "$PROJECT_DIR" ]] && error "Directory already exists: $PROJECT_DIR"
log "Creating project: $PROJECT_NAME ($PROJECT_TYPE)"
mkdir -p "$PROJECT_DIR"/{src,tests,docs,.github/workflows}
cd "$PROJECT_DIR"
# Initialize git
git init
cat > .gitignore <<'GITIGNORE'
__pycache__/
*.pyc
node_modules/
.env
dist/
build/
*.egg-info/
.venv/
coverage/
GITIGNORE
# Language-specific setup
case "$PROJECT_TYPE" in
python)
log "Setting up Python project..."
python3 -m venv .venv
source .venv/bin/activate
cat > requirements.txt <<'EOF'
pytest>=7.0
pytest-cov>=4.0
ruff>=0.1.0
EOF
pip install -r requirements.txt -q
cat > src/__init__.py <<'EOF'
"""${PROJECT_NAME} - A Python project."""
EOF
cat > tests/test_main.py <<'EOF'
def test_placeholder():
assert True
EOF
log "Running initial tests..."
pytest tests/ -v
;;
node)
log "Setting up Node.js project..."
npm init -y -q
npm install --save-dev jest eslint -q
cat > src/index.js <<'EOF'
module.exports = { hello: (name) => `Hello, ${name}!` };
EOF
mkdir -p tests
cat > tests/index.test.js <<'EOF'
const { hello } = require('../src/index');
test('greets by name', () => {
expect(hello('World')).toBe('Hello, World!');
});
EOF
npx jest --passWithNoTests
;;
esac
# Create pre-commit hook
cat > .git/hooks/pre-commit <<'HOOK'
#!/bin/bash
set -e
echo "Running pre-commit checks..."
if [[ -f "requirements.txt" ]]; then
ruff check src/ tests/ || exit 1
pytest tests/ -q || exit 1
elif [[ -f "package.json" ]]; then
npx eslint src/ || exit 1
npx jest --passWithNoTests || exit 1
fi
echo "All checks passed."
HOOK
chmod +x .git/hooks/pre-commit
# Initial commit
git add -A
git commit -m "feat: initialize $PROJECT_NAME ($PROJECT_TYPE)"
log "Project ready at $PROJECT_DIR"
log "Files created:"
find . -not -path './.git/*' -not -path './node_modules/*' \
-not -path './.venv/*' -type f | sort | head -20
Tips for Writing Better Bash Scripts
Always start scripts with set -euo pipefail. The -e flag exits on any error, -u treats unset variables as errors, and pipefail catches failures in piped commands. Use shellcheck to lint your scripts. It catches common mistakes like unquoted variables, missing error handling, and POSIX compatibility issues.
Quote your variables. Always use "$variable" instead of $variable to prevent word splitting and glob expansion. Use [[ ]] instead of [ ] for conditionals as it handles empty strings and pattern matching safely. Prefer local variables inside functions to avoid polluting the global scope.
Bash scripting rewards the developer who respects its quirks. It is not the prettiest language, but for automating command-line workflows, it remains unmatched in reach and convenience.