Stackvora.tech

Bash Scripting: 15 Patterns Every Sysadmin Needs

Effective Bash scripting is a cornerstone of system administration, enabling automation, configuration management, and efficient troubleshooting. Beyond…

Effective Bash scripting is a cornerstone of system administration, enabling automation, configuration management, and efficient troubleshooting. Beyond simple one-liners, robust scripts require adherence to best practices and the application of proven patterns to ensure reliability, maintainability, and security. This article outlines 15 essential Bash scripting patterns every sysadmin should master, covering fundamental boilerplate to advanced data handling and error management.

1. Strict Mode Boilerplate

Always start your scripts with a strict mode boilerplate. This immediately catches common errors and makes debugging significantly easier.

#!/usr/bin/env bash
# Strict Mode:
# -e: Exit immediately if a command exits with a non-zero status.
# -u: Treat unset variables as an error and exit immediately.
# -o pipefail: The return value of a pipeline is the status of the last command to exit with a non-zero status.
set -euo pipefail

# IFS: Internal Field Separator. Setting it to newline and tab helps prevent word splitting issues
# with spaces in filenames or other string data.
IFS=
  

\n\t'

# trap: Execute a command when a signal is received or an EXIT/ERR condition occurs.
# This specific trap reports the line number of an error.
trap 'echo "Error on line $LINENO" >&2' ERR

Version Note: set -o pipefail is available in Bash 3.0 and later. Most modern systems use Bash 4.x or 5.x.

2. Argument Parsing with getopts or case

For scripts requiring configurable options, robust argument parsing is crucial. For simple cases, a while/case loop suffices; for more complex options, getopts is preferred.

2.1 Simple case-based Parsing

Suitable for a small number of custom short or long options.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

NAME=""
VERBOSE=0

# A usage function is essential for user guidance
usage() {
  echo "Usage: $0 [-n <name>] [-v] [-h]"
  echo "  -n, --name <name> : Specify a name."
  echo "  -v, --verbose     : Enable verbose output."
  echo "  -h, --help        : Display this help message."
  exit 1
}

# Shift until all arguments are processed
while [[ $# -gt 0 ]]; do
  case "$1" in
    -n|--name)
      if [[ -z "$2" || "$2" =~ ^- ]]; then # Ensure value exists and isn't another flag
        echo "Error: Argument for $1 is missing or invalid." >&2
        usage
      fi
      NAME="$2"
      shift 2 # Consume argument and its value
      ;;
    -v|--verbose)
      VERBOSE=1
      shift # Consume argument
      ;;
    -h|--help)
      usage
      ;;
    *)
      echo "Error: Unknown argument '$1'" >&2
      usage
      ;;
  esac
done

if [[ -z "$NAME" ]]; then
  echo "Error: A name must be provided." >&2
  usage
fi

echo "Hello, $NAME!"
if [[ "$VERBOSE" -eq 1 ]]; then
  echo "Verbose mode is enabled."
fi

2.2 Advanced getopts Parsing

getopts is a Bash built-in for parsing short options. It's more robust and handles option grouping (e.g., -abc).

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

NAME=""
VERBOSE=0
FORCE=0

usage() {
  echo "Usage: $0 [-n <name>] [-vfh]"
  echo "  -n <name> : Specify a name."
  echo "  -v        : Enable verbose output."
  echo "  -f        : Force operation."
  echo "  -h        : Display this help message."
  exit 1
}

# 'n:' means -n requires an argument. 'vfh' are flags.
while getopts "n:vfh" opt; do
  case "$opt" in
    n) NAME="$OPTARG" ;;
    v) VERBOSE=1 ;;
    f) FORCE=1 ;;
    h) usage ;;
    \?) # Invalid option
      echo "Error: Invalid option -$OPTARG" >&2
      usage
      ;;
    :) # Missing argument for an option
      echo "Error: Option -$OPTARG requires an argument." >&2
      usage
      ;;
  esac
done
shift $((OPTIND-1)) # Shift remaining non-option arguments

if [[ -z "$NAME" ]]; then
  echo "Error: A name must be provided using -n." >&2
  usage
fi

echo "Hello, $NAME!"
if [[ "$VERBOSE" -eq 1 ]]; then
  echo "Verbose mode enabled."
fi
if [[ "$FORCE" -eq 1 ]]; then
  echo "Force mode enabled."
fi
echo "Remaining arguments: $*"

Note: getopts only handles single-character options. For long options (--name), external tools like getopt (with an 's') or manual case parsing are necessary.

3. Safe Iteration with while IFS= read -r

Processing line-by-line input, especially from files or command output, can be tricky due to whitespace or special characters. This pattern ensures robust handling.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

echo "Processing file_list.txt..."
# Example: List files, then process each one.
# Note: Using process substitution <(...) to avoid a subshell for 'while' loop variables.
# Alternatively, a pipe like 'cat file_list.txt | while ...' would run the loop in a subshell,
# meaning variables modified inside the loop would not persist outside.
while IFS= read -r line; do
  if [[ -n "$line" ]]; then # Check if line is not empty
    echo "Processing entry: '$line'"
    # Example: Perform an action on the file/path
    # if [[ -f "$line" ]]; then
    #   echo "  File '$line' exists."
    # fi
  fi
done < <(find . -maxdepth 1 -type f -print0 | xargs -0 basename)

# Example with a pipe, where variables inside the loop would be local to the subshell:
# SomeVar="Initial"
# ls -1 | while IFS= read -r file; do
#   echo "File: $file"
#   SomeVar="Changed" # This change would not be visible outside the loop
# done
# echo "SomeVar outside loop: $SomeVar" # Would still be "Initial" if piped directly
# To avoid subshell with pipes, use named pipes or process substitution as above.
  • IFS=: Temporarily unsets the Internal Field Separator to prevent word splitting.
  • read -r: Prevents backslash escapes from being interpreted.
  • < <(...): Process substitution, passes output as a temporary file to stdin, avoiding subshell issues with variable scope.
  • xargs -0: Works with find ... -print0 to handle filenames with spaces or special characters safely.

4. Functions for Modularity and Reusability

Break down complex scripts into smaller, manageable functions. This improves readability, maintainability, and allows for code reuse.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

log_info() {
  echo "[INFO] $(date +'%Y-%m-%d %H:%M:%S') - $1"
}

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

check_service_status() {
  local service_name="$1"
  log_info "Checking status of service: $service_name"
  if systemctl is-active --quiet "$service_name"; then
    log_info "Service '$service_name' is running."
    return 0
  else
    log_error "Service '$service_name' is not running."
    return 1 # Though log_error exits, this is good practice for return codes
  fi
}

# Main script logic
log_info "Starting script execution."
SERVICE_TO_CHECK="nginx" # Example service
check_service_status "$SERVICE_TO_CHECK"

log_info "Script finished successfully."
  • local: Use local for variables inside functions to prevent them from clashing with global variables.
  • return: Functions return exit codes (0 for success, non-zero for failure).

5. Error Handling and Logging

Beyond the strict mode trap ERR, implement specific error handling and informative logging.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

LOG_FILE="/var/log/my_script.log" # Or to stderr for immediate feedback
declare -g SCRIPT_START_TIME # Global variable declared with declare -g

# Initialize log file (only once)
init_log() {
  SCRIPT_START_TIME=$(date +%s)
  echo "--- Script started on $(date +'%Y-%m-%d %H:%M:%S') ---" >> "$LOG_FILE"
}

log() {
  local level="$1"
  local message="$2"
  echo "$(date +'%Y-%m-%d %H:%M:%S') [${level^^}] $message" | tee -a "$LOG_FILE"
}

handle_error() {
  local exit_code="$?"
  local line_num="$1"
  log "ERROR" "Command failed on line $line_num with exit code $exit_code."
  log "ERROR" "Script execution aborted."
  exit "$exit_code"
}

# Set the trap after defining the error handler
trap 'handle_error $LINENO' ERR

# Ensure log file is initialized
init_log

log "INFO" "Performing critical operation..."
# Example of a command that might fail
# cp /nonexistent/file /tmp/output.txt

log "INFO" "Simulating a successful command."
mkdir -p /tmp/my_script_output
log "INFO" "Directory /tmp/my_script_output created successfully."

# Simulate an error
# false

log "INFO" "All operations completed without reported errors."
# Final cleanup or summary
log "INFO" "Script completed successfully."
echo "--- Script finished on $(date +'%Y-%m-%d %H:%M:%S') ---" >> "$LOG_FILE"
  • tee -a "$LOG_FILE": Logs to both stdout/stderr and a file.
  • trap 'handle_error $LINENO' ERR: Catches errors and calls handle_error with the line number.

6. Temporary Files and Directories

Create and clean up temporary resources reliably.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

TMP_DIR=$(mktemp -d -t my_script_XXXXXX)
# mktemp -t creates in default temp dir, -p /path can specify it.
# -d creates a directory.
# XXXXXX is replaced by a random string.

cleanup() {
  echo "Cleaning up temporary directory: $TMP_DIR"
  rm -rf "$TMP_DIR"
}

trap cleanup EXIT # Ensure cleanup runs on exit, even on error

echo "Temporary directory created: $TMP_DIR"
echo "Working in temp dir..."
echo "Some content" > "$TMP_DIR/temp_file.txt"
cat "$TMP_DIR/temp_file.txt"

# Simulate some work
sleep 2

echo "Script finished. Cleanup will be performed."

trap cleanup EXIT: Guarantees that the cleanup function runs when the script exits, regardless of success or failure.

7. Checking for Dependencies

Verify that required commands or executables are available.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

REQUIRED_COMMANDS=("jq" "curl" "systemctl")

check_dependencies() {
  for cmd in "${REQUIRED_COMMANDS[@]}"; do
    if ! command -v "$cmd" &>/dev/null; then
      echo "Error: Required command '$cmd' not found. Please install it." >&2
      exit 1
    fi
  done
  echo "All required commands are available."
}

check_dependencies
# Script logic continues here, knowing 'jq', 'curl', 'systemctl' are present.
curl --version &>/dev/null && echo "curl is working."
jq --version &>/dev/null && echo "jq is working."
systemctl --version &>/dev/null && echo "systemctl is working."
  • command -v "$cmd" &>/dev/null: Checks if a command exists in the PATH without producing output.

8. Process Locking (Flock)

Prevent multiple instances of a script from running concurrently.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

LOCK_FILE="/var/tmp/my_script.lock"
# -n: Fail immediately if lock cannot be obtained
# -x: Exclusive lock
# FD 200: Arbitrary file descriptor for the lock
if ! flock -n 200; then
  echo "Error: Script is already running. Exiting." >&2
  exit 1
fi

echo "Lock acquired. Running script..."
# Simulate long-running task
sleep 10
echo "Task complete. Releasing lock."

# The lock is automatically released when the script exits or FD 200 is closed.
# The 'exec 200>' part opens the file for writing and assigns it to FD 200.
exec 200> "$LOCK_FILE"
  • flock -n 200: Attempts to acquire a non-blocking exclusive lock on file descriptor 200.
  • exec 200> "$LOCK_FILE": Opens $LOCK_FILE for writing and assigns it to file descriptor 200. This must be done before flock.

9. Safely Handling Passwords/Sensitive Data

Avoid putting sensitive data directly in scripts or command arguments.

  • Environment Variables: Set sensitive data in the environment before running the script (e.g., export DB_PASS="secret").
  • Read from Stdin: Prompt for input securely.
  • Temporary Files (encrypted): Store temporarily encrypted and decrypt only when needed.
  • Secret Management Systems: HashiCorp Vault, AWS Secrets Manager, Kubernetes Secrets.
#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

read_password() {
  local prompt_msg="${1:-"Enter password: "}"
  # -s: Do not echo input characters.
  # -r: Raw input, prevents backslash escapes.
  read -sr -p "$prompt_msg" MY_PASSWORD
  echo # Add a newline as -s suppresses it
  echo "Password stored (in memory for now)."
  # In a real scenario, use MY_PASSWORD and then unset it
  # unset MY_PASSWORD # after use
}

# Example usage
# if [[ -z "${DB_PASSWORD:-}" ]]; then # Check if already in environment
#   read_password "Enter database password: "
# else
#   MY_PASSWORD="$DB_PASSWORD"
#   echo "Password retrieved from environment variable."
# fi
#
# # Use MY_PASSWORD for a command, e.g., connecting to a database
# # mysql -u user -p"$MY_PASSWORD" -h localhost

echo "Password input example:"
read_password "Enter a test password: "

10. Associative Arrays (Bash 4+)

For key-value pairs, associative arrays are more efficient and readable than parallel indexed arrays.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

# Declare an associative array
declare -A HOST_IPS
HOST_IPS=(
  ["webserver01"]="192.168.1.10"
  ["dbserver01"]="192.168.1.11"
  ["cacheserver01"]="192.168.1.12"
)

echo "Known hosts and IPs:"
for host in "${!HOST_IPS[@]}"; do # Iterate over keys
  echo "  Host: $host, IP: ${HOST_IPS[$host]}"
done

# Add a new entry
HOST_IPS["appserver01"]="192.168.1.13"
echo "Added appserver01: ${HOST_IPS["appserver01"]}"

# Check if a key exists
if [[ -v HOST_IPS["webserver01"] ]]; then
  echo "webserver01 is defined with IP: ${HOST_IPS["webserver01"]}"
fi

# Remove an entry
unset HOST_IPS["cacheserver01"]
echo "Removed cacheserver01."

echo "Updated host list:"
for host in "${!HOST_IPS[@]}"; do
  echo "  Host: $host, IP: ${HOST_IPS[$host]}"
done

Version Note: Associative arrays require Bash 4.0 or newer. Use bash --version to check.

11. Parameter Expansions

Bash provides powerful parameter expansions for string manipulation, default values, and error handling.

Expansion Description Example
${VAR:-default} Use default if VAR is unset or null. HOST=${HOSTNAME:-localhost}
${VAR:=default} Assign default to VAR if unset or null, then use it. LOG_DIR=${LOG_DIR:=/var/log/myapp}
${VAR:?error_msg} If VAR is unset or null, print error_msg and exit. USER_NAME=${SUDO_USER:?Error: Must run with sudo}
${VAR:+alternate} Use alternate if VAR is set and not null. DEBUG_FLAG=${VERBOSE:+--debug}
${VAR#pattern} Remove shortest matching pattern from front. FILE="/path/to/file.txt"; echo "${FILE#*/}" → path/to/file.txt
${VAR##pattern} Remove longest matching pattern from front. FILE="/path/to/file.txt"; echo "${FILE##*/}" → file.txt
${VAR%pattern} Remove shortest matching pattern from back. FILE="file.txt.bak"; echo "${FILE%.*}" → file.txt
${VAR%%pattern} Remove longest matching pattern from back. FILE="file.tar.gz"; echo "${FILE%%.*}" → file
${VAR/pattern/replace} Replace first match of pattern with replace. IP="192.168.1.1"; echo "${IP/./-}" → 192-168.1.1
${VAR//pattern/replace} Replace all matches of pattern with replace. IP="192.168.1.1"; echo "${IP//./-}" → 192-168-1-1

12. Process Substitution and Here Documents/Strings

Flexible ways to provide input to commands.

12.1 Process Substitution (<(...) and >(...))

Treats the output of a process as a file, or accepts input for a process from a file-like object.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

# Compare two lists using diff
diff <(sort file1.txt) <(sort file2.txt)

# Read output of a command into a loop without subshell issues
while IFS= read -r line; do
  echo "Processed: $line"
done < <(ls -1)

# Feeding output to a command that expects a file
# SomeCommand --input-file <(generate_data_on_fly)

12.2 Here Documents (<<EOF)

Provides multi-line input to a command.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

cat <<EOF
This is a multi-line string.
It can contain variables like: HOSTNAME=$HOSTNAME
And even commands: Current Date is $(date)

Variables are expanded unless 'EOF' is quoted ('<<'EOF'').
EOF

# Using a quoted EOF prevents variable expansion and command substitution
echo "--- Quoted EOF example ---"
cat <<'END_RAW'
This is raw text.
HOSTNAME will not be expanded: $HOSTNAME
$(date) will also not be expanded.
END_RAW

12.3 Here Strings (<<<)

Provides a single string as standard input.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

read -r name <<<"John Doe"
echo "Hello, $name"

# Feed a string to a command
echo "Processing JSON string:"
jq '.name' <<<'{"id": 1, "name": "Stackvora", "active": true}'

13. Shellcheck Integration

Use ShellCheck to lint your scripts. It catches common pitfalls, syntax errors, and adherence to best practices.

#!/usr/bin/env bash
# This script has a known ShellCheck warning for demonstration.
# SC2034: VAR appears unused. Verify it is not needed or move it.
UNUSED_VAR="some_value"

my_func() {
  local_var="local" # SC2034: local_var appears unused.
  echo "Hello from function"
}

# Example of a common error that ShellCheck flags:
# for file in $(find . -name "*.txt"); do # SC2044: For loops over find output are fragile. Use 'find ... -exec' or 'while read -r'.
#   echo "Processing $file"
# done

# To run ShellCheck:
# shellcheck myscript.sh
# To ignore specific checks on a line:
# UNUSED_VAR="some_value" # shellcheck disable=SC2034

my_func

Integrate shellcheck into your development workflow and CI/CD pipelines.

14. Conditional Execution with [[ ... ]]

The [[ ... ]] construct is a Bash keyword offering more features and robustness than the traditional [ ... ] (test command).

  • Regex Matching: [[ $VAR =~ regex ]]
  • Logical Operators: && (AND), || (OR), ! (NOT)
  • No Word Splitting: Variables inside [[ ... ]] don't need to be quoted (though it's still good practice).
#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

FILE_NAME="document.pdf"
USER_INPUT="yes"
NUMBER=10

if [[ -f "$FILE_NAME" && -s "$FILE_NAME" ]]; then
  echo "$FILE_NAME exists and is not empty."
fi

if [[ "$USER_INPUT" == "yes" || "$USER_INPUT" == "y" ]]; then
  echo "User confirmed."
fi

if [[ "$NUMBER" -gt 5 && "$NUMBER" -lt 15 ]]; then
  echo "Number is between 5 and 15."
fi

# Regex matching (Bash 3.0+)
if [[ "$FILE_NAME" =~ \.pdf$ ]]; then
  echo "$FILE_NAME is a PDF file."
fi

STRING="hello world"
if [[ "$STRING" == *world* ]]; then # Globbing (wildcard matching)
  echo "String contains 'world'."
fi

15. Array Operations

Working with indexed arrays to store lists of items.

#!/usr/bin/env bash
set -euo pipefail
IFS=
  

\n\t'

# Declare an indexed array
declare -a FRUITS=("Apple" "Banana" "Cherry")

echo "All fruits: ${FRUITS[@]}"

# Access elements
echo "First fruit: ${FRUITS[0]}"
echo "Second fruit: ${FRUITS[1]}"

# Add elements
FRUITS+=("Date") # Add to the end
FRUITS[4]="Elderberry" # Add at specific index

echo "Updated fruits: ${FRUITS[@]}"

# Get number of elements
echo "Number of fruits: ${#FRUITS[@]}"

# Iterate over elements
echo "Listing fruits:"
for fruit in "${FRUITS[@]}"; do
  echo " - $fruit"
done

# Remove an element
unset FRUITS[1] # Removes "Banana"
echo "Fruits after removing Banana: ${FRUITS[@]}" # Notice the gap in indices if not careful

# Iterate with indices
echo "Listing fruits with indices:"
for i in "${!FRUITS[@]}"; do
  echo "  Index $i: ${FRUITS[$i]}"
done

# Create a new array to re-index (if needed after unsetting)
# NEW_FRUITS=()
# for fruit in "${FRUITS[@]}"; do
#   NEW_FRUITS+=("$fruit")
# done
# FRUITS=("${NEW_FRUITS[@]}")
# echo "Re-indexed fruits: ${FRUITS[@]}"
  • "${FRUITS[@]}": Expands to all elements, each as a separate word (safest for iteration).
  • "${!FRUITS[@]}": Expands to all indices.

Common Pitfalls

  • Unquoted Variables: Always quote variables when they might contain spaces or special characters (e.g., "$VAR"). This prevents unexpected word splitting or globbing. The set -euo pipefail and IFS= \n\t' boilerplate helps, but quoting is still crucial.
  • Subshells and Variable Scope: Commands in a pipeline (e.g., cmd1 | while read ...) often run in a subshell, meaning variables set or modified within the while loop will not persist after the pipe. Use process substitution (< <(...)) or rewrite to avoid pipes for variables that need to persist.
  • Exit Codes Ignored: Without set -e, a command can fail silently, leading to unexpected behavior later in the script.
  • Over-reliance on ls for Parsing: Never parse the output of ls (e.g., for file in $(ls)). It's unreliable due to spaces, newlines, and special characters in filenames. Use find -print0 | xargs -0 or globbing (for file in *; do ...).
  • Not Using local in Functions: Forgetting local can lead to unintended global variable modifications and hard-to-debug side effects.
  • Inadequate Error Handling: Simply exiting on error is often not enough. Log context, line numbers, and provide meaningful messages to aid troubleshooting.

Back to the knowledge base · Ask the AI assistant