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.
#!/usr/bin/env bash
# ==============================================================================# Script : <script name># Purpose: <brief description># Usage: <script.sh> [options] <args># ==============================================================================set -euo pipefail
# ===== CONSTANTS =====readonlySCRIPT_NAME="$(basename "$0")"readonlySCRIPT_DIR="$(cd"$(dirname "$0")"&&pwd)"readonlyTIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"# ===== CONFIGURATION =====readonlyLOG_FILE="${LOG_FILE:-/var/log/${SCRIPT_NAME}.log}"readonlySLACK_WEBHOOK="${SLACK_WEBHOOK:-}"# ===== FUNCTIONS =====log(){locallevel="$1"localmessage="$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
EOFexit"${1:-0}"}cleanup(){localexit_code=$?if[[$exit_code -ne 0]];then log "ERROR""Script failed with exit code $exit_code"fi# Cleanup code herereturn$exit_code}# ===== MAIN =====main(){# Parse argumentswhile[[$# -gt 0]];docase"$1" in
-h|--help) usage 0;; -v|--verbose)VERBOSE=true;shift;; *)break;;esacdone# Validate required argumentsif[[$# -lt 1]];then log "ERROR""Missing required argument" usage 1fi log "INFO""Script started"# Main logic here log "INFO""Script completed"}# ===== ENTRY POINT =====trap cleanup EXIT
main "$@"
Why this template?
Component
Reason
#!/usr/bin/env bash
Portable, finds bash in PATH
set -euo pipefail
Exit on error, undefined vars, pipe failures
readonly
Prevents 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 bashif[[$EUID -ne 0]];thenecho"This script must be run as root" >&2exit1fi
Error handling
set -euo pipefail
1
2
3
4
5
6
# Always place at the top of the scriptset -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 ERRtrap'echo "Error at line $LINENO" >&2' ERR
# Trap EXIT (always runs, success or failure)trap cleanup EXIT
# Trap specific signalstrap'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 codesexit0# Successexit1# General errorexit2# Misuse of shell commandexit126# Permission problemexit127# Command not foundexit 128+n # Fatal error signal "n"# In scriptvalidate_input(){if[[ -z "$1"]];thenecho"ERROR: Missing argument" >&2return1fireturn0}
log(){locallevel="$1"localmessage="$2"locallog_file="${LOG_FILE:-/tmp/${SCRIPT_NAME}.log}"echo"[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message"| tee -a "$log_file"}# Usagelog "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 scriptDATABASE_URL="postgres://user:pass@localhost:5432/db"API_KEY="sk-1234567890"# GOOD — load from config filereadonlyCONFIG_FILE="${CONFIG_FILE:-${SCRIPT_DIR}/config.env}"if[[ -f "$CONFIG_FILE"]];thenset -a
source"$CONFIG_FILE"set +a
elseecho"ERROR: Config file not found: $CONFIG_FILE" >&2exit1fi# Validate required config: "${DATABASE_URL:?DATABASE_URL is required}": "${API_KEY:?API_KEY is required}"
# Function with local variablesdeploy_app(){localenvironment="$1"localversion="$2"# Validate inputsif[[ -z "$environment"|| -z "$version"]];thenecho"Usage: deploy_app <environment> <version>" >&2return1fi# Local scope — no conflict with global varslocaldeploy_dir="/opt/apps/${environment}"# Logicecho"Deploying $version to $environment..."# Deploy logic herereturn0}# Function with return value via stdoutget_latest_version(){localapp_name="$1"# Return value via stdout, NOT via return curl -s "https://api.example.com/apps/${app_name}/version"}# UsageVERSION=$(get_latest_version "myapp")deploy_app "production""$VERSION"
# BAD — word splitting, globbingrm $FILEcp $SOURCE$DESTecho$VARIABLE# GOOD — always quoterm "$FILE"cp "$SOURCE""$DEST"echo"$VARIABLE"# Exception: arrays and [[ ]]for file in "${FILES[@]}";doecho"$file"doneif[[$var=="pattern"]];thenecho"matched"fi
Command substitution
1
2
3
4
5
6
7
# BADcd$(dirname $0)files=$(ls)# GOODcd"$(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 installgit clone https://github.com/bats-core/bats-core.git
cd bats-core
./install.sh /usr/local
# Or use package managerbrew install bats # macOSapt-get install bats # Debian/Ubuntu
#!/usr/bin/env bats
# tests/test_deploy.sh# Setup — runs before each testsetup(){exportSCRIPT_DIR="$(dirname "$BATS_TEST_FILENAME")/.."exportTEST_ENV="staging"}# Teardown — runs after each testteardown(){# 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 testsbats tests/
# Run a single filebats 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
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: