Advanced Bash Scripting for Developers

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.

ADVERTISEMENT
#!/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.

ADVERTISEMENT

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.