Variables, Loops & Conditionals: Bash Control Structures

Master the building blocks of Bash scripting. Learn how to store data in variables, make decisions with conditionals, and automate repetitive tasks with loops. Essential skills for any DevOps engineer.

V
Variables

Store and manipulate data

Types: Strings, Numbers, Arrays

Scopes: Local, Global, Environment

VAR="value", ${VAR}, declare
C
Conditionals

Make decisions in scripts

if, elif, else, case

Comparison operators

if [ condition ]; then ... fi
L
Loops

Automate repetitive tasks

for, while, until

Loop control: break, continue

for i in list; do ... done

Part 1: Variables Deep Dive

Variable Types and Declaration

#!/bin/bash

# 1. Basic variables
STRING_VAR="Hello World"
NUMBER_VAR=42
BOOLEAN_VAR=true

echo "String: $STRING_VAR"
echo "Number: $NUMBER_VAR"
echo "Boolean: $BOOLEAN_VAR"

# 2. Variable expansion
FILE="backup.tar.gz"
echo "Filename: ${FILE}"
echo "Without extension: ${FILE%.*}"      # backup.tar
echo "Extension only: ${FILE##*.}"       # gz
echo "Basename: ${FILE##*/}"             # backup.tar.gz

# 3. Default values
CONFIG_FILE=${CONFIG_FILE:-"/etc/app.conf"}
BACKUP_DIR=${BACKUP_DIR:-"$HOME/backups"}
echo "Config: $CONFIG_FILE"
echo "Backup: $BACKUP_DIR"

# 4. Command substitution
CURRENT_DATE=$(date)
FILE_COUNT=$(ls | wc -l)
SYSTEM_UPTIME=$(uptime -p)

echo "Date: $CURRENT_DATE"
echo "Files: $FILE_COUNT"
echo "Uptime: $SYSTEM_UPTIME"

# 5. Arithmetic operations
COUNT=10
COUNT=$((COUNT + 5))
MULTIPLY=$((COUNT * 2))
DIVIDE=$((MULTIPLY / 3))
MODULO=$((COUNT % 3))

echo "Count: $COUNT"
echo "Multiply: $MULTIPLY"
echo "Divide: $DIVIDE"
echo "Modulo: $MODULO"

# 6. Increment/Decrement
((COUNT++))      # Increment
((COUNT--))      # Decrement
((COUNT += 5))   # Add 5
((COUNT -= 2))   # Subtract 2

Arrays: Storing Multiple Values

#!/bin/bash

# 1. Indexed arrays
SERVERS=("web1" "web2" "db1" "cache1")
echo "All servers: ${SERVERS[@]}"
echo "First server: ${SERVERS[0]}"
echo "Server count: ${#SERVERS[@]}"

# Add elements
SERVERS+=("web3")
SERVERS[4]="db2"

# Loop through array
for SERVER in "${SERVERS[@]}"; do
    echo "Server: $SERVER"
done

# 2. Associative arrays (bash 4+)
declare -A CONFIG
CONFIG["hostname"]="server1"
CONFIG["port"]="8080"
CONFIG["environment"]="production"

echo "Hostname: ${CONFIG[hostname]}"
echo "Port: ${CONFIG[port]}"

# Loop through associative array
for KEY in "${!CONFIG[@]}"; do
    echo "$KEY = ${CONFIG[$KEY]}"
done

# 3. Read array from file
readarray -t LOG_FILES < <(find /var/log -name "*.log" | head -5)
echo "Log files: ${LOG_FILES[@]}"

# 4. Array slicing
ALL_NUMBERS=(1 2 3 4 5 6 7 8 9 10)
FIRST_THREE=("${ALL_NUMBERS[@]:0:3}")
LAST_THREE=("${ALL_NUMBERS[@]: -3}")
MIDDLE=("${ALL_NUMBERS[@]:3:4}")

echo "First three: ${FIRST_THREE[@]}"
echo "Last three: ${LAST_THREE[@]}"
echo "Middle: ${MIDDLE[@]}"

Special Variables and Parameters

Variable Description Example $0 Script name echo "Script: $0" $1, $2, ... Positional parameters echo "First: $1, Second: $2" $# Number of arguments echo "Args count: $#" $@ All arguments as separate words for arg in "$@"; do echo "$arg"; done $* All arguments as single string echo "All args: $*" $? Exit status of last command ls /tmp; echo "Exit: $?" $$ Current process PID echo "PID: $$" $! PID of last background job sleep 10 & echo "Job PID: $!"

Part 2: Conditionals Mastery

Start
Check Condition
True → Execute Commands
False → Next Condition/End

Basic if Statements

#!/bin/bash

# 1. Simple if
if [ -f "/etc/passwd" ]; then
    echo "File exists"
fi

# 2. if-else
if [ "$USER" = "root" ]; then
    echo "Running as root"
else
    echo "Running as $USER"
fi

# 3. if-elif-else
read -p "Enter score (0-100): " SCORE

if [ "$SCORE" -ge 90 ]; then
    GRADE="A"
elif [ "$SCORE" -ge 80 ]; then
    GRADE="B"
elif [ "$SCORE" -ge 70 ]; then
    GRADE="C"
elif [ "$SCORE" -ge 60 ]; then
    GRADE="D"
else
    GRADE="F"
fi

echo "Grade: $GRADE"

# 4. Nested if
if [ -d "/var/www" ]; then
    echo "Web directory exists"
    if [ -w "/var/www" ]; then
        echo "And is writable"
    else
        echo "But not writable"
    fi
fi

Advanced Conditional Operators

Operator Description Example -a or && Logical AND [ "$a" -gt 0 -a "$a" -lt 10 ] -o or || Logical OR [ "$a" -eq 0 -o "$b" -eq 0 ] ! Logical NOT [ ! -f "$file" ] -eq, -ne Equal, Not equal [ "$a" -eq "$b" ] -lt, -gt Less than, Greater than [ "$a" -lt "$b" ] -le, -ge Less or equal, Greater or equal [ "$a" -le "$b" ] -z, -n String empty, Not empty [ -z "$var" ] =, != String equal, Not equal [ "$str1" = "$str2" ]

Case Statements: Elegant Multiple Conditions

#!/bin/bash

# 1. Basic case
read -p "Enter action (start|stop|restart|status): " ACTION

case $ACTION in
    start)
        echo "Starting service..."
        systemctl start nginx
        ;;
    stop)
        echo "Stopping service..."
        systemctl stop nginx
        ;;
    restart)
        echo "Restarting service..."
        systemctl restart nginx
        ;;
    status)
        echo "Checking status..."
        systemctl status nginx
        ;;
    *)  # Default case
        echo "Unknown action: $ACTION"
        echo "Usage: $0 {start|stop|restart|status}"
        exit 1
        ;;
esac

# 2. Pattern matching
read -p "Enter filename: " FILENAME

case $FILENAME in
    *.txt)
        echo "Text file"
        ;;
    *.jpg|*.jpeg|*.png|*.gif)
        echo "Image file"
        ;;
    *.sh)
        echo "Shell script"
        chmod +x "$FILENAME"
        ;;
    *)
        echo "Unknown file type"
        ;;
esac

# 3. Multiple patterns with fallthrough
read -p "Enter HTTP status code: " CODE

case $CODE in
    200)
        echo "OK - Success"
        ;;
    301|302)
        echo "Redirect"
        ;;
    400|401|403|404)
        echo "Client Error"
        ;;
    500|501|502|503)
        echo "Server Error"
        ;;
    [1-9][0-9][0-9])
        echo "Other HTTP code"
        ;;
    *)
        echo "Invalid code"
        ;;
esac

Modern [[ ]] Double Bracket Syntax

#!/bin/bash

# Use [[ ]] instead of [ ] for better features

# 1. String comparison (no quotes needed)
if [[ $USER == root ]]; then
    echo "Running as root"
fi

# 2. Pattern matching
FILENAME="backup_20251207.tar.gz"
if [[ $FILENAME == backup_*.tar.gz ]]; then
    echo "Valid backup file"
fi

# 3. Regex matching
EMAIL="user@example.com"
if [[ $EMAIL =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
    echo "Valid email"
fi

# 4. Logical operators
if [[ -f "/etc/passwd" && -r "/etc/passwd" ]]; then
    echo "File exists and is readable"
fi

# 5. Compound conditions
NUMBER=15
if [[ $NUMBER -gt 10 && $NUMBER -lt 20 ]]; then
    echo "Number between 10 and 20"
fi

# 6. String operations
STR="Hello World"
if [[ ${#STR} -gt 5 ]]; then
    echo "String length: ${#STR}"
fi

if [[ $STR == *World* ]]; then
    echo "Contains 'World'"
fi

Part 3: Loops in Depth

For Loops: Iterating Over Collections

#!/bin/bash

# 1. Basic for loop
for COLOR in red green blue yellow; do
    echo "Color: $COLOR"
done

# 2. Iterate over array
SERVERS=("web1" "web2" "db1" "cache1")
for SERVER in "${SERVERS[@]}"; do
    echo "Pinging $SERVER..."
    ping -c 1 "$SERVER" > /dev/null 2>&1
    if [ $? -eq 0 ]; then
        echo "$SERVER is UP"
    else
        echo "$SERVER is DOWN"
    fi
done

# 3. C-style for loop
for ((i=1; i<=10; i++)); do
    echo "Iteration $i"
done

# 4. Loop with step
for ((i=0; i<=100; i+=10)); do
    echo "Progress: $i%"
done

# 5. Loop through command output
for USER in $(cut -d: -f1 /etc/passwd | head -10); do
    echo "User: $USER"
    id "$USER"
done

# 6. Loop through files
for FILE in /var/log/*.log; do
    if [ -f "$FILE" ]; then
        echo "Processing: $(basename "$FILE")"
        wc -l "$FILE"
    fi
done

# 7. Loop with index
FRUITS=("apple" "banana" "cherry" "date")
for i in "${!FRUITS[@]}"; do
    echo "Index $i: ${FRUITS[$i]}"
done

While Loops: Condition-Based Iteration

#!/bin/bash

# 1. Basic while loop
COUNT=1
while [ $COUNT -le 5 ]; do
    echo "Count: $COUNT"
    ((COUNT++))
done

# 2. Read file line by line
while read LINE; do
    echo "Line: $LINE"
done < /etc/hosts

# 3. Process command output
df -h | while read FILESYSTEM SIZE USED AVAIL USE MOUNT; do
    if [[ $USE > 80% ]]; then
        echo "WARNING: $FILESYSTEM is $USE full"
    fi
done

# 4. Infinite loop with break
while true; do
    read -p "Enter command (q to quit): " CMD
    if [[ $CMD == "q" ]]; then
        echo "Goodbye!"
        break
    fi
    echo "You entered: $CMD"
done

# 5. Wait for condition
SERVER_UP=false
ATTEMPT=1
MAX_ATTEMPTS=10

while [ $SERVER_UP = false ] && [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
    echo "Checking server... Attempt $ATTEMPT/$MAX_ATTEMPTS"
    
    if curl -s http://localhost:8080 > /dev/null; then
        SERVER_UP=true
        echo "Server is UP!"
    else
        echo "Server not responding"
        ((ATTEMPT++))
        sleep 2
    fi
done

# 6. Process multiple files
find /tmp -name "*.tmp" -type f | while read FILE; do
    echo "Cleaning: $FILE"
    rm -f "$FILE"
done

Until Loops: Execute Until Condition Met

#!/bin/bash

# 1. Basic until loop (opposite of while)
COUNT=10
until [ $COUNT -lt 1 ]; do
    echo "Countdown: $COUNT"
    ((COUNT--))
done
echo "Blast off!"

# 2. Wait for service to start
PORT_OPEN=false
until [ $PORT_OPEN = true ]; do
    echo "Checking port 8080..."
    if nc -z localhost 8080 2>/dev/null; then
        PORT_OPEN=true
        echo "Port 8080 is open"
    else
        echo "Port not ready, waiting..."
        sleep 2
    fi
done

# 3. Process until user quits
INPUT=""
until [[ $INPUT == "quit" ]]; do
    read -p "Enter data (quit to exit): " INPUT
    if [[ $INPUT != "quit" ]]; then
        echo "You entered: $INPUT"
        # Process input here
    fi
done

# 4. Until with timeout
TIMEOUT=30
START_TIME=$(date +%s)
CONNECTED=false

until [ $CONNECTED = true ] || [ $(($(date +%s) - START_TIME)) -gt $TIMEOUT ]; do
    echo "Trying to connect..."
    if mysql -h localhost -u root -e "SELECT 1" 2>/dev/null; then
        CONNECTED=true
        echo "Connected to database"
    else
        echo "Connection failed, retrying..."
        sleep 2
    fi
done

if [ $CONNECTED = false ]; then
    echo "ERROR: Connection timeout after $TIMEOUT seconds"
fi

Loop Control: break and continue

#!/bin/bash

# 1. break - exit loop early
for i in {1..10}; do
    if [ $i -eq 5 ]; then
        echo "Breaking at $i"
        break
    fi
    echo "Iteration: $i"
done

# 2. continue - skip to next iteration
for i in {1..10}; do
    if [ $((i % 2)) -eq 0 ]; then
        continue  # Skip even numbers
    fi
    echo "Odd number: $i"
done

# 3. Nested loops with labeled break
for i in {1..3}; do
    echo "Outer loop: $i"
    for j in {1..3}; do
        if [ $j -eq 2 ]; then
            break 2  # Break out of 2 levels
        fi
        echo "  Inner loop: $j"
    done
done

# 4. Practical example: Find first match
FILES=("/etc/passwd" "/etc/group" "/nonexistent" "/etc/hosts")
for FILE in "${FILES[@]}"; do
    if [ ! -f "$FILE" ]; then
        echo "ERROR: $FILE not found"
        continue
    fi
    
    echo "Processing $FILE"
    if grep -q "root" "$FILE"; then
        echo "Found 'root' in $FILE"
        break  # Stop after first match
    fi
done

# 5. Skip errors with continue
for HOST in web1 web2 db1 invalid cache1; do
    echo "Testing $HOST..."
    if ! ping -c 1 "$HOST" > /dev/null 2>&1; then
        echo "  Skipping $HOST (unreachable)"
        continue
    fi
    
    echo "  $HOST is reachable"
    # Further processing...
done

Practical DevOps Examples

Example 1: Configuration Validator

#!/bin/bash
# config-validator.sh

set -e

echo "=== Configuration Validator ==="

# Arrays of required and optional settings
REQUIRED_SETTINGS=("DB_HOST" "DB_PORT" "API_KEY")
OPTIONAL_SETTINGS=("LOG_LEVEL" "CACHE_TTL" "DEBUG_MODE")

# Check required settings
MISSING_SETTINGS=()
for SETTING in "${REQUIRED_SETTINGS[@]}"; do
    if [ -z "${!SETTING}" ]; then
        MISSING_SETTINGS+=("$SETTING")
    fi
done

if [ ${#MISSING_SETTINGS[@]} -gt 0 ]; then
    echo "ERROR: Missing required settings:"
    for SETTING in "${MISSING_SETTINGS[@]}"; do
        echo "  - $SETTING"
    done
    exit 1
fi

# Validate settings
ERRORS=()

# Validate DB_PORT is number
if ! [[ $DB_PORT =~ ^[0-9]+$ ]]; then
    ERRORS+=("DB_PORT must be a number")
elif [ $DB_PORT -lt 1 ] || [ $DB_PORT -gt 65535 ]; then
    ERRORS+=("DB_PORT must be between 1-65535")
fi

# Validate LOG_LEVEL if set
if [ -n "$LOG_LEVEL" ]; then
    case $LOG_LEVEL in
        debug|info|warn|error)
            # Valid level
            ;;
        *)
            ERRORS+=("LOG_LEVEL must be: debug, info, warn, or error")
            ;;
    esac
fi

# Report results
if [ ${#ERRORS[@]} -eq 0 ]; then
    echo "✓ All configuration settings are valid"
    
    # Show configuration
    echo ""
    echo "Configuration Summary:"
    for SETTING in "${REQUIRED_SETTINGS[@]}" "${OPTIONAL_SETTINGS[@]}"; do
        if [ -n "${!SETTING}" ]; then
            echo "  $SETTING=${!SETTING}"
        fi
    done
else
    echo "ERROR: Configuration validation failed:"
    for ERROR in "${ERRORS[@]}"; do
        echo "  - $ERROR"
    done
    exit 1
fi

Example 2: Log File Processor

#!/bin/bash
# log-processor.sh

set -e

LOG_DIR="/var/log/app"
OUTPUT_FILE="/tmp/log-analysis-$(date +%Y%m%d).csv"
ERROR_THRESHOLD=10

echo "=== Log File Processor ==="

# Check if log directory exists
if [ ! -d "$LOG_DIR" ]; then
    echo "ERROR: Log directory $LOG_DIR not found"
    exit 1
fi

# Initialize counters
declare -A ERROR_COUNTS
declare -A IP_COUNTS
TOTAL_LINES=0
ERROR_LINES=0

# Process each log file
for LOG_FILE in "$LOG_DIR"/*.log; do
    if [ ! -f "$LOG_FILE" ]; then
        continue
    fi
    
    echo "Processing: $(basename "$LOG_FILE")"
    
    # Read file line by line
    while read -r LINE; do
        ((TOTAL_LINES++))
        
        # Count errors
        if [[ $LINE =~ ERROR|FATAL ]]; then
            ((ERROR_LINES++))
            
            # Extract error type
            if [[ $LINE =~ \[([A-Z_]+)\] ]]; then
                ERROR_TYPE="${BASH_REMATCH[1]}"
                ((ERROR_COUNTS["$ERROR_TYPE"]++))
            fi
            
            # Extract IP address
            if [[ $LINE =~ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+) ]]; then
                IP="${BASH_REMATCH[1]}"
                ((IP_COUNTS["$IP"]++))
            fi
        fi
    done < "$LOG_FILE"
done

# Generate report
echo ""
echo "=== Analysis Report ==="
echo "Total lines processed: $TOTAL_LINES"
echo "Error lines: $ERROR_LINES"
echo "Error rate: $((ERROR_LINES * 100 / TOTAL_LINES))%"

if [ $ERROR_LINES -gt $ERROR_THRESHOLD ]; then
    echo "⚠ WARNING: Error count exceeds threshold ($ERROR_THRESHOLD)"
fi

echo ""
echo "Error types:"
for ERROR_TYPE in "${!ERROR_COUNTS[@]}"; do
    echo "  $ERROR_TYPE: ${ERROR_COUNTS[$ERROR_TYPE]}"
done | sort -nr -k2

echo ""
echo "Top IPs with errors:"
for IP in "${!IP_COUNTS[@]}"; do
    echo "  $IP: ${IP_COUNTS[$IP]}"
done | sort -nr -k2 | head -5

# Export to CSV
echo "timestamp,total_lines,error_lines,error_rate" > "$OUTPUT_FILE"
echo "$(date),$TOTAL_LINES,$ERROR_LINES,$((ERROR_LINES * 100 / TOTAL_LINES))" >> "$OUTPUT_FILE"

echo ""
echo "Report saved to: $OUTPUT_FILE"

Example 3: Server Deployment Automation

#!/bin/bash
# server-deploy.sh

set -e

# Configuration
SERVER_LIST=("web1.example.com" "web2.example.com" "db1.example.com")
DEPLOY_USER="deploy"
APP_DIR="/var/www/app"
BACKUP_DIR="/backups"
MAX_RETRIES=3

# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'

print_status() { echo -e "${GREEN}[✓]${NC} $1"; }
print_error() { echo -e "${RED}[✗]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[!]${NC} $1"; }

deploy_to_server() {
    local SERVER=$1
    local ATTEMPT=1
    
    while [ $ATTEMPT -le $MAX_RETRIES ]; do
        echo "Deploying to $SERVER (Attempt $ATTEMPT/$MAX_RETRIES)"
        
        # SSH command with timeout
        if ssh -o ConnectTimeout=10 -o BatchMode=yes "$DEPLOY_USER@$SERVER" "
            set -e
            echo 'Creating backup...'
            BACKUP_NAME=\"app-backup-\$(date +%Y%m%d-%H%M%S).tar.gz\"
            tar -czf \"$BACKUP_DIR/\$BACKUP_NAME\" -C \"$APP_DIR\" .
            
            echo 'Pulling latest code...'
            cd \"$APP_DIR\"
            git pull origin main
            
            echo 'Installing dependencies...'
            npm install --production
            
            echo 'Running migrations...'
            npm run migrate
            
            echo 'Restarting service...'
            sudo systemctl restart app
        "; then
            print_status "Deployment to $SERVER successful"
            return 0
        else
            print_warning "Deployment to $SERVER failed (Attempt $ATTEMPT)"
            ((ATTEMPT++))
            sleep 2
        fi
    done
    
    print_error "Failed to deploy to $SERVER after $MAX_RETRIES attempts"
    return 1
}

# Main deployment
FAILED_SERVERS=()

for SERVER in "${SERVER_LIST[@]}"; do
    echo ""
    echo "=== Processing $SERVER ==="
    
    # Check server connectivity
    if ! ping -c 1 -W 2 "$SERVER" > /dev/null 2>&1; then
        print_error "$SERVER is unreachable"
        FAILED_SERVERS+=("$SERVER")
        continue
    fi
    
    # Deploy to server
    if deploy_to_server "$SERVER"; then
        print_status "$SERVER deployment completed"
    else
        FAILED_SERVERS+=("$SERVER")
    fi
done

# Summary
echo ""
echo "=== Deployment Summary ==="
if [ ${#FAILED_SERVERS[@]} -eq 0 ]; then
    print_status "All servers deployed successfully!"
else
    print_error "Deployment failed on: ${FAILED_SERVERS[*]}"
    exit 1
fi

Advanced Patterns

# Pattern 1: Process substitution with while
while read LINE; do
echo "Processing: $LINE"
done < <(find /path -name "*.log")
# Processes output of find command without creating subshell
# Pattern 2: Select menu
PS3="Select option: "
select OPTION in "Start" "Stop" "Restart" "Quit"; do
case $OPTION in
"Start") ... ;;
"Quit") break ;;
esac
done

Best Practices Checklist

Always quote variables: "$VAR"
Use [[ ]] instead of [ ] for conditionals
Prefer $(command) over backticks
Use declare for typed variables
Initialize arrays properly: arr=()
Use local for function variables
Handle empty variables: ${VAR:-default}
Validate user input before using
Use break and continue for loop control
Prefer case over multiple if-elif for string matching
Use arithmetic expansion: $((expression))
Test with different input scenarios
Add comments for complex logic
Common Pitfalls to Avoid:
1. Unquoted variables leading to word splitting
2. Using = for string comparison instead of == in [[ ]]
3. Forgetting that [ ] requires spaces: [ "$a" = "$b" ]
4. Using -a/-o inside [[ ]] (use &&/|| instead)
5. Not handling empty arrays properly
6. Infinite loops without exit conditions
7. Not checking command exit codes
8. Using variables before they're set
Performance Tips:
• Use built-in string operations instead of external commands
• Avoid unnecessary subshells $(command)
• Use process substitution <(command) for command output
• Prefer shell builtins over external utilities when possible
• Use arrays instead of parsing strings multiple times
• Minimize file I/O in loops

Mastering Control Structures

Variables, loops, and conditionals are the foundation of all programming. In Bash scripting for DevOps, mastering these concepts means you can automate almost anything.

Remember: The best way to learn is by doing. Take a manual process you do regularly and automate it using these building blocks. Start simple, test thoroughly, and iterate.

Practice Exercise: Create a script that monitors system resources (CPU, memory, disk) and sends alerts when thresholds are exceeded. Use variables for thresholds, conditionals for checking, and loops for continuous monitoring.

Quick Reference:
Variables: VAR="value", ${VAR}, $(command), $((math))
Arrays: arr=("a" "b"), ${arr[@]}, ${#arr[@]}
Conditionals: if [[ condition ]]; then ... fi
Case: case $var in pattern) ... ;; esac
For loop: for i in list; do ... done
While loop: while condition; do ... done
Until loop: until condition; do ... done
Loop control: break, continue