BLOG // [DEVOPS] Bash Best Practices: A comprehensive guide to mastering scripts
Bash Best Practices: A comprehensive guide to mastering scripts

Bash Best Practices: A comprehensive guide to mastering scripts

Comprehensive Bash DevOps best practices: shebang, set -euo pipefail, modular functions, logging, config management, exit codes, shellcheck, and testing with bats.

DEVOPS BASH

Bash best practices summary

After 19 posts in this series, we’ve covered everything from basics to advanced topics — from variables, loops, functions, to security and cloud CLI. This post will summarize all best practices to help you write professional, maintainable, and secure Bash scripts in DevOps.

Why best practices? Bash scripts run automatically on servers, in pipelines, and are often maintained by multiple people. Without standards, scripts become “spaghetti code” — hard to debug, error-prone, and potentially insecure.


Standard script template

Every new script should start with this template:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#!/usr/bin/env bash
# ==============================================================================
# Script : <script name>
# Purpose: <brief description>
# Usage: <script.sh> [options] <args>
# ==============================================================================

set -euo pipefail

# ===== CONSTANTS =====
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"

# ===== CONFIGURATION =====
readonly LOG_FILE="${LOG_FILE:-/var/log/${SCRIPT_NAME}.log}"
readonly SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"

# ===== FUNCTIONS =====
log() {
    local level="$1"
    local message="$2"
    echo "[$TIMESTAMP] [$level] $message" | tee -a "$LOG_FILE"
}

usage() {
    cat << EOF
Usage: $SCRIPT_NAME <option> <argument>

Options:
    -h, --help      Show this help message
    -v, --verbose   Enable verbose output

Examples:
    $SCRIPT_NAME --verbose server01
EOF
    exit "${1:-0}"
}

cleanup() {
    local exit_code=$?
    if [[ $exit_code -ne 0 ]]; then
        log "ERROR" "Script failed with exit code $exit_code"
    fi
    # Cleanup code here
    return $exit_code
}

# ===== MAIN =====
main() {
    # Parse arguments
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help) usage 0 ;;
            -v|--verbose) VERBOSE=true; shift ;;
            *) break ;;
        esac
    done

    # Validate required arguments
    if [[ $# -lt 1 ]]; then
        log "ERROR" "Missing required argument"
        usage 1
    fi

    log "INFO" "Script started"
    # Main logic here
    log "INFO" "Script completed"
}

# ===== ENTRY POINT =====
trap cleanup EXIT
main "$@"

Why this template?

ComponentReason
#!/usr/bin/env bashPortable, finds bash in PATH
set -euo pipefailExit on error, undefined vars, pipe failures
readonlyPrevents accidental modification of constants
function log()Standardized logging, easier debugging
usage()Documents script usage
cleanup()Trap EXIT for cleanup
main()Clear entry point

Correct shebang

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# DON'T use
#!/bin/bash           # May not be the correct path

# USE
#!/usr/bin/env bash   # Portable, works on all systems

# If script requires root
#!/usr/bin/env bash
if [[ $EUID -ne 0 ]]; then
    echo "This script must be run as root" >&2
    exit 1
fi

Error handling

set -euo pipefail

1
2
3
4
5
6
# Always place at the top of the script
set -euo pipefail

# -e: Exit immediately on error
# -u: Error on undefined variables
# -o pipefail: Exit if any command in pipe fails

Trap errors

1
2
3
4
5
6
7
8
# Trap ERR
trap 'echo "Error at line $LINENO" >&2' ERR

# Trap EXIT (always runs, success or failure)
trap cleanup EXIT

# Trap specific signals
trap 'echo "Interrupted"; exit 130' INT TERM

Meaningful exit codes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Return meaningful exit codes
exit 0    # Success
exit 1    # General error
exit 2    # Misuse of shell command
exit 126  # Permission problem
exit 127  # Command not found
exit 128+n  # Fatal error signal "n"

# In script
validate_input() {
    if [[ -z "$1" ]]; then
        echo "ERROR: Missing argument" >&2
        return 1
    fi
    return 0
}

Logging

Log with levels

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Constants
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly NC='\033[0m' # No Color

log_info() {
    echo -e "${GREEN}[$(date '+%H:%M:%S')] [INFO]${NC} $1"
}

log_warn() {
    echo -e "${YELLOW}[$(date '+%H:%M:%S')] [WARN]${NC} $1" >&2
}

log_error() {
    echo -e "${RED}[$(date '+%H:%M:%S')] [ERROR]${NC} $1" >&2
}

# Usage
log_info "Starting deployment"
log_warn "Disk usage high"
log_error "Connection failed"

Log to file

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
log() {
    local level="$1"
    local message="$2"
    local log_file="${LOG_FILE:-/tmp/${SCRIPT_NAME}.log}"

    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message" | tee -a "$log_file"
}

# Usage
log "INFO" "Deployment started"
log "ERROR" "Failed to connect to database"

Configuration management

Separate config from code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# BAD — hardcode in script
DATABASE_URL="postgres://user:pass@localhost:5432/db"
API_KEY="sk-1234567890"

# GOOD — load from config file
readonly CONFIG_FILE="${CONFIG_FILE:-${SCRIPT_DIR}/config.env}"

if [[ -f "$CONFIG_FILE" ]]; then
    set -a
    source "$CONFIG_FILE"
    set +a
else
    echo "ERROR: Config file not found: $CONFIG_FILE" >&2
    exit 1
fi

# Validate required config
: "${DATABASE_URL:?DATABASE_URL is required}"
: "${API_KEY:?API_KEY is required}"

Config file template

1
2
3
4
5
6
# config.env (chmod 600)
APP_NAME=myapp
DATABASE_URL=postgres://user:pass@localhost:5432/db
REDIS_URL=redis://localhost:6379
LOG_LEVEL=info
SLACK_WEBHOOK=https://hooks.slack.com/services/xxx

Functions

Function structure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Function with local variables
deploy_app() {
    local environment="$1"
    local version="$2"

    # Validate inputs
    if [[ -z "$environment" || -z "$version" ]]; then
        echo "Usage: deploy_app <environment> <version>" >&2
        return 1
    fi

    # Local scope — no conflict with global vars
    local deploy_dir="/opt/apps/${environment}"

    # Logic
    echo "Deploying $version to $environment..."
    # Deploy logic here

    return 0
}

# Function with return value via stdout
get_latest_version() {
    local app_name="$1"
    # Return value via stdout, NOT via return
    curl -s "https://api.example.com/apps/${app_name}/version"
}

# Usage
VERSION=$(get_latest_version "myapp")
deploy_app "production" "$VERSION"

Source external functions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# lib/logger.sh
log_info() { echo "[INFO] $1"; }
log_error() { echo "[ERROR] $1" >&2; }

# lib/utils.sh
validate_email() { [[ "$1" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; }

# Main script
source "${SCRIPT_DIR}/lib/logger.sh"
source "${SCRIPT_DIR}/lib/utils.sh"

log_info "Starting script"
validate_email "$USER_EMAIL" || { log_error "Invalid email"; exit 1; }

Input validation

Validate arguments

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Check required args
if [[ $# -lt 2 ]]; then
    echo "Usage: $0 <hostname> <port>" >&2
    exit 1
fi

HOSTNAME="$1"
PORT="$2"

# Validate hostname
if [[ ! "$HOSTNAME" =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$ ]]; then
    echo "ERROR: Invalid hostname: $HOSTNAME" >&2
    exit 1
fi

# Validate port
if [[ ! "$PORT" =~ ^[0-9]+$ ]] || (( PORT < 1 || PORT > 65535 )); then
    echo "ERROR: Invalid port: $PORT (must be 1-65535)" >&2
    exit 1
fi

Validate files

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
require_file() {
    local file="$1"
    local description="${2:-file}"

    if [[ ! -f "$file" ]]; then
        echo "ERROR: $description not found: $file" >&2
        return 1
    fi
    return 0
}

require_executable() {
    local cmd="$1"
    if ! command -v "$cmd" &>/dev/null; then
        echo "ERROR: Required command not found: $cmd" >&2
        return 1
    fi
    return 0
}

# Usage
require_file ".env" "Config file"
require_executable "docker"
require_executable "kubectl"

Variable quoting

Always quote when expanding

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# BAD — word splitting, globbing
rm $FILE
cp $SOURCE $DEST
echo $VARIABLE

# GOOD — always quote
rm "$FILE"
cp "$SOURCE" "$DEST"
echo "$VARIABLE"

# Exception: arrays and [[ ]]
for file in "${FILES[@]}"; do
    echo "$file"
done

if [[ $var == "pattern" ]]; then
    echo "matched"
fi

Command substitution

1
2
3
4
5
6
7
# BAD
cd $(dirname $0)
files=$(ls)

# GOOD
cd "$(dirname "$0")"
files=$(ls)  # ls output is usually safe, but still be careful

Testing with bats

Installation

1
2
3
4
5
6
7
8
# Clone and install
git clone https://github.com/bats-core/bats-core.git
cd bats-core
./install.sh /usr/local

# Or use package manager
brew install bats  # macOS
apt-get install bats  # Debian/Ubuntu

Writing tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/usr/bin/env bats
# tests/test_deploy.sh

# Setup — runs before each test
setup() {
    export SCRIPT_DIR="$(dirname "$BATS_TEST_FILENAME")/.."
    export TEST_ENV="staging"
}

# Teardown — runs after each test
teardown() {
    # Cleanup
    rm -rf /tmp/test_deploy_* 2>/dev/null || true
}

# Test case
@test "deploy_app returns 0 on success" {
    run bash "$SCRIPT_DIR/deploy.sh" "$TEST_ENV" "1.0.0"
    [ "$status" -eq 0 ]
    [[ "$output" == *"Deploying"* ]]
}

@test "deploy_app fails with invalid environment" {
    run bash "$SCRIPT_DIR/deploy.sh" "invalid-env" "1.0.0"
    [ "$status" -eq 1 ]
    [[ "$output" == *"Invalid environment"* ]]
}

@test "validate_port rejects non-numeric" {
    source "$SCRIPT_DIR/lib/utils.sh"
    run validate_port "abc"
    [ "$status" -eq 1 ]
}

@test "validate_port rejects out of range" {
    source "$SCRIPT_DIR/lib/utils.sh"
    run validate_port "99999"
    [ "$status" -eq 1 ]
}

Running tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Run all tests
bats tests/

# Run a single file
bats tests/test_deploy.sh

# Output
 ✓ deploy_app returns 0 on success
 ✓ deploy_app fails with invalid environment
 ✓ validate_port rejects non-numeric
 ✓ validate_port rejects out of range

4 tests, 0 failures

Shellcheck integration

Setup

1
2
3
4
5
6
7
8
9
# Install
brew install shellcheck  # macOS
apt-get install shellcheck  # Debian/Ubuntu

# Check script
shellcheck script.sh

# Fix all issues
shellcheck -f diff script.sh | patch -p1

Common fixes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# SC2086: Double quote variables
# BAD
echo $1
# GOOD
echo "$1"

# SC2046: Quote command substitution
# BAD
cd $(dirname $0)
# GOOD
cd "$(dirname "$0")"

# SC2034: Remove unused variables
# SC2155: Declare and assign separately
# BAD
local output=$(command)
# GOOD
local output
output=$(command)

# SC2164: Use cd ... || exit in case cd fails
# BAD
cd /some/dir
# GOOD
cd /some/dir || exit 1

CI/CD integration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# GitHub Actions
lint:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - name: Install shellcheck
      run: sudo apt-get install -y shellcheck
    - name: Run shellcheck
      run: shellcheck -s bash scripts/*.sh lib/*.sh

# pre-commit hook
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/koalaman/shellcheck-precommit
    rev: v0.9.0
    hooks:
      - id: shellcheck

Hands-on: Production-ready script

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#!/usr/bin/env bash
# ==============================================================================
# Script : health-checker.sh
# Purpose: System health check with alerting
# Usage: health-checker.sh [--webhook URL] [--threshold PERCENT]
# ==============================================================================

set -euo pipefail

# ===== CONSTANTS =====
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"

# ===== DEFAULTS =====
DISK_THRESHOLD=80
MEMORY_THRESHOLD=90
SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
LOG_FILE="${LOG_FILE:-/var/log/${SCRIPT_NAME}.log}"

# ===== FUNCTIONS =====
log() {
    local level="$1"
    local message="$2"
    echo "[$TIMESTAMP] [$level] $message" | tee -a "$LOG_FILE"
}

usage() {
    cat << EOF
Usage: $SCRIPT_NAME [OPTIONS]

Options:
    -w, --webhook URL       Slack webhook URL
    -d, --disk PERCENT      Disk threshold (default: 80)
    -m, --memory PERCENT    Memory threshold (default: 90)
    -h, --help              Show this help

Examples:
    $SCRIPT_NAME --webhook https://hooks.slack.com/xxx --disk 90
EOF
    exit "${1:-0}"
}

send_alert() {
    local message="$1"
    if [[ -n "$SLACK_WEBHOOK" ]]; then
        curl -s -X POST -H 'Content-type: application/json' \
            --data "{\"text\":\"[$SCRIPT_NAME] $message\"}" "$SLACK_WEBHOOK" >/dev/null 2>&1
    fi
}

check_disk() {
    local usage
    usage=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')

    if (( usage > DISK_THRESHOLD )); then
        log "WARN" "Disk usage ${usage}% exceeds threshold ${DISK_THRESHOLD}%"
        send_alert "Disk usage ${usage}% on $(hostname)"
        return 1
    fi
    log "INFO" "Disk usage: ${usage}%"
    return 0
}

check_memory() {
    local usage
    usage=$(free | awk '/Mem:/ {printf "%.0f", $3/$2 * 100}')

    if (( usage > MEMORY_THRESHOLD )); then
        log "WARN" "Memory usage ${usage}% exceeds threshold ${MEMORY_THRESHOLD}%"
        send_alert "Memory usage ${usage}% on $(hostname)"
        return 1
    fi
    log "INFO" "Memory usage: ${usage}%"
    return 0
}

cleanup() {
    local exit_code=$?
    if [[ $exit_code -ne 0 ]]; then
        log "ERROR" "Script failed with exit code $exit_code"
    fi
    return $exit_code
}

# ===== MAIN =====
main() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -w|--webhook) SLACK_WEBHOOK="$2"; shift 2 ;;
            -d|--disk) DISK_THRESHOLD="$2"; shift 2 ;;
            -m|--memory) MEMORY_THRESHOLD="$2"; shift 2 ;;
            -h|--help) usage 0 ;;
            *) log "ERROR" "Unknown option: $1"; usage 1 ;;
        esac
    done

    log "INFO" "=== Health Check Started ==="

    local failed=0
    check_disk || ((failed++))
    check_memory || ((failed++))

    if (( failed > 0 )); then
        log "WARN" "$failed check(s) failed"
        exit 1
    fi

    log "INFO" "=== All checks passed ==="
}

# ===== ENTRY POINT =====
trap cleanup EXIT
main "$@"

Test the script

1
2
3
4
5
# Run tests
$ bash -n health-checker.sh  # Check syntax
$ shellcheck health-checker.sh  # Lint
$ bats tests/test_health.sh  # Run tests
$ ./health-checker.sh --help  # Test help output

Implementation notes

  • Start with a template: Always use a standard template when writing new scripts. Saves time and ensures consistency.
  • Shellcheck in CI: Require shellcheck to pass before merging. Catch bugs early.
  • Test before deploy: Write bats tests for critical scripts. Especially for automation running in production.
  • Document --help: Every script should have clear --help output. Team members won’t need to ask.
  • Consistent logging: Use the same log format across all scripts. Easier to aggregate and search.
  • Separate config: Don’t hardcode config in scripts. Use .env or config files.
  • Review process: When reviewing Bash scripts, check for: hardcoded secrets, missing validation, unused variables, loose permissions.

Conclusion

The 20-post series has covered everything from basics to advanced topics — from variables, loops, functions, to security, cloud automation, and testing.

Best practices are not rigid rules but guidelines for writing scripts:

  • Readable: Clear comments, naming conventions, modular functions
  • Maintainable: Error handling, logging, config management
  • Secure: Input validation, secrets management, shellcheck
  • Testable: Bats tests, CI/CD integration

Apply these principles and you’ll write Bash scripts professionally and confidently in your DevOps workflow.

RESPONSES & DISCUSSION