Skip to content
Shell Scripting Guide — Variables, Conditionals, Loops, Functions, Error Handling

Shell Scripting Guide — Variables, Conditionals, Loops, Functions, Error Handling

DodaTech Updated Jun 20, 2026 11 min read

Shell scripting is the automation backbone of Linux administration. This guide teaches production-ready Bash scripting — from variables and conditionals to functions, error handling, and debugging — so you can automate repetitive tasks, build deployment pipelines, and create monitoring tools.

What You’ll Learn

You’ll write Bash scripts with proper variable handling, conditional logic, loop structures, reusable functions, robust error handling, and debugging techniques. You’ll also see how DodaZIP uses shell scripts for its build pipeline and how Durga Antivirus Pro uses scripts for log analysis and threat detection automation.

Why Shell Scripting Matters

A single shell script can replace hours of manual work. Deploying an application to 10 servers becomes a one-command operation. Log analysis, backup rotation, health checks, and system monitoring are all scripting tasks. Shell scripting is the force multiplier for every system administrator.

Learning Path

    flowchart LR
  A[Security Hardening] --> B[Shell Scripting<br/>You are here]
  B --> C[Monitoring & Logging]
  C --> D[Infrastructure Automation]
  style B fill:#f90,color:#fff
  

Script Structure and Shebang

Every Bash script starts with a shebang line that tells the system which interpreter to use:

#!/bin/bash
# A minimal script
echo "Hello, World!"

Save as hello.sh, make executable, and run:

chmod +x hello.sh
./hello.sh

Variables

# Assignment (no spaces around =)
name="Alice"
count=42
today=$(date +%Y-%m-%d)     # Command substitution
files=$(ls -la)              # Alternative: `ls -la`

# Usage — always quote variables to prevent word splitting
echo "$name"
echo "Count: $count"
echo "Today: $today"

# Read-only variables
readonly API_KEY="abc123"

# Default values
echo "${GREETING:-Hello}"     # If GREETING unset, use "Hello"
echo "${NAME:?Name is required}"  # Error if unset

# Positional parameters
echo "Script: $0"
echo "First arg: $1"
echo "Second arg: $2"
echo "All args: $*"
echo "Args count: $#"

Conditionals

if/elif/else

if [ "$1" = "start" ]; then
    echo "Starting service..."
elif [ "$1" = "stop" ]; then
    echo "Stopping service..."
else
    echo "Usage: $0 {start|stop}"
    exit 1
fi

Test Operators

# File tests
[ -f "$file" ]     # File exists and is regular
[ -d "$dir" ]      # Directory exists
[ -x "$binary" ]   # File is executable
[ -s "$file" ]     # File exists and is non-empty

# String tests
[ -z "$var" ]      # String is empty
[ -n "$var" ]      # String is non-empty
[ "$a" = "$b" ]    # Strings equal
[ "$a" != "$b" ]   # Strings different

# Numeric tests
[ "$num" -eq 0 ]   # Equal
[ "$num" -lt 10 ]  # Less than
[ "$num" -gt 100 ] # Greater than

# Compound conditions
[ -f "$file" ] && [ -s "$file" ]   # AND
[ -z "$var" ] || [ -n "$var2" ]    # OR

case Statements

case "$1" in
    start)
        echo "Starting..."
        /usr/bin/myapp start
        ;;
    stop|kill)
        echo "Stopping..."
        /usr/bin/myapp stop
        ;;
    restart)
        "$0" stop
        "$0" start
        ;;
    *)
        echo "Usage: $0 {start|stop|restart}"
        exit 1
        ;;
esac

Loops

for Loop

# Loop over a list
for server in web-01 web-02 db-01; do
    echo "Deploying to $server..."
    ssh "$server" "systemctl restart myapp"
done

# Loop over numbers
for i in {1..5}; do
    echo "Attempt $i"
done

# Loop over files
for log in /var/log/*.log; do
    echo "Processing: $log"
    gzip "$log"
done

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

while Loop

# Read a file line by line
while IFS= read -r line; do
    echo "Line: $line"
done < /etc/hosts

# Infinite loop with condition
count=0
while [ "$count" -lt 5 ]; do
    echo "Count: $count"
    count=$((count + 1))
done

# Watch a process
while pgrep -x "myapp" >/dev/null; do
    echo "myapp is running..."
    sleep 5
done
echo "myapp has stopped!"

until Loop

# Run until condition is true
until ping -c 1 -W 1 "$HOST" >/dev/null 2>&1; do
    echo "Waiting for $HOST to be reachable..."
    sleep 2
done
echo "$HOST is up!"

Functions

# Define a function
health_check() {
    local service="$1"
    local port="$2"

    if nc -z localhost "$port"; then
        echo "$service is running on port $port"
        return 0
    else
        echo "$service is NOT running!"
        return 1
    fi
}

# Call the function
health_check "nginx" 80
health_check "mysql" 3306

# Function with return value
is_valid_ip() {
    local ip="$1"
    if [[ "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
        return 0
    else
        return 1
    fi
}

if is_valid_ip "192.168.1.100"; then
    echo "Valid IP"
fi

Variable Scope

Use local inside functions to avoid global scope pollution:

# Bad — global variable leak
set_name() {
    name="Alice"     # Modifies global variable!
}

# Good — local variable
set_name() {
    local name="Alice"   # Scoped to function
    echo "$name"
}

Error Handling

Exit Codes and Error Traps

#!/bin/bash
set -euo pipefail    # Fail on error, unset vars, pipe failures

# Or explicitly:
set -e               # Exit immediately on error
set -u               # Error on unset variables
set -o pipefail      # Pipes fail if any command fails

# Custom error handler
error_handler() {
    local line="$1"
    local command="$2"
    echo "ERROR: Command '$command' failed at line $line"
    exit 1
}
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR

# Check command success
if ! mkdir -p /backups/daily; then
    echo "Failed to create backup directory"
    exit 1
fi

Input Validation

validate_input() {
    local input="$1"

    # Check if empty
    if [ -z "$input" ]; then
        echo "Error: Input cannot be empty"
        return 1
    fi

    # Check for dangerous characters
    if echo "$input" | grep -q '[;&|`$]'; then
        echo "Error: Input contains dangerous characters"
        return 1
    fi

    return 0
}

read -p "Enter filename: " filename
if validate_input "$filename"; then
    cat "$filename"
fi

Arrays

# Declare an array
servers=("web-01" "web-02" "db-01")

# Access elements
echo "${servers[0]}"     # web-01
echo "${servers[@]}"     # All elements
echo "${#servers[@]}"    # Length

# Loop over array
for server in "${servers[@]}"; do
    echo "Deploying to $server"
done

# Associative arrays (Bash 4+)
declare -A ports
ports[nginx]=80
ports[mysql]=3306
ports[redis]=6379

echo "nginx port: ${ports[nginx]}"

Debugging

# Run with debug mode
bash -x script.sh

# Debug specific sections
set -x    # Start debugging
# ... code to debug ...
set +x    # Stop debugging

# Verbose mode (show lines before execution)
bash -v script.sh

# Check syntax without running
bash -n script.sh

Common Debugging Patterns

#!/bin/bash

# Print what you're about to run
echo "Executing: cp $SOURCE $DEST"
cp "$SOURCE" "$DEST"

# Check after each critical step
echo "Backup size: $(du -sh "$DEST")"

# Use shellcheck for static analysis
# Install: sudo apt install shellcheck
# Run: shellcheck script.sh

Production Script Template

#!/bin/bash
# Production-ready script template
# Usage: ./deploy.sh [environment]

set -euo pipefail

# Configuration
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="/var/log/$(basename "$0" .sh).log"
readonly TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

# Logging
log() {
    local level="$1"
    shift
    echo "[$TIMESTAMP] [$level] $*" | tee -a "$LOG_FILE"
}

info()  { log "INFO" "$*"; }
warn()  { log "WARN" "$*"; }
error() { log "ERROR" "$*"; exit 1; }

# Cleanup on exit
cleanup() {
    local exit_code=$?
    info "Script finished with exit code $exit_code"
    # Remove temp files, unlock resources, etc.
}
trap cleanup EXIT

# Usage
usage() {
    echo "Usage: $0 [options] <environment>"
    echo "Options:"
    echo "  -v    Verbose output"
    echo "  -d    Dry run (no changes)"
    exit 1
}

# Parse arguments
VERBOSE=false
DRY_RUN=false
while getopts "vd" opt; do
    case "$opt" in
        v) VERBOSE=true ;;
        d) DRY_RUN=true ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

ENVIRONMENT="${1:?Environment is required (staging|production)}"

info "Starting deployment to $ENVIRONMENT"
info "Dry run: $DRY_RUN"

if [ "$DRY_RUN" = true ]; then
    info "Would execute: ansible-playbook -i $ENVIRONMENT deploy.yml"
    exit 0
fi

# Main logic
info "Running deployment..."
# ansible-playbook -i "$ENVIRONMENT" deploy.yml

info "Deployment complete!"

Common Scripting Mistakes

1. Not Quoting Variables

echo $var splits on whitespace and expands globs. Always use echo "$var". The difference between rm -rf $dir and rm -rf "$dir" is potentially catastrophic.

2. Using for Loops Over Command Output

for file in $(ls *.txt) breaks on filenames with spaces. Use for file in *.txt or while IFS= read -r file.

3. Missing Error Handling

Commands fail silently without set -e. A failed mkdir, cp, or rm continues execution, potentially corrupting data.

4. Running Scripts with sh Instead of bash

sh may be dash (Debian) or a minimal POSIX shell without arrays, [[ ]], or source. Always use #!/bin/bash if you use Bash features.

5. Hardcoding Paths

/home/alice/backups/ works only on your machine. Use relative paths, environment variables, or configuration files.

6. Not Using Shellcheck

Shellcheck catches 90% of common mistakes — unquoted variables, missing error handling, deprecated syntax. Run it before deploying scripts.

7. Leaving Sensitive Data in Scripts

Hardcoding passwords, API keys, or tokens in scripts is a security risk. Use environment variables or encrypted config files.

Practice Questions

1. What does set -euo pipefail do? -e: exit on first error. -u: error on unset variables. -o pipefail: entire pipeline fails if any command fails.

2. How do you make a variable read-only in Bash? readonly VAR_NAME="value". Any subsequent attempt to modify it will fail.

3. What’s the difference between $@ and $*? $@ expands each argument as a separate word (preserving quotes). $* expands all arguments as a single word. Use "$@" for safe argument forwarding.

4. How do you check if a command succeeded? Check $? which holds the exit code of the last command. 0 = success, non-zero = failure. Or use if command; then to check directly.

5. Challenge: Write a script that monitors a log file for ERROR entries and sends an alert if 10 or more errors appear within 5 minutes. Include proper error handling and run continuously. Answer: A while loop with tail -f, counting ERROR lines with grep, comparing against a threshold, and using mail or curl to send an alert. Include trap for clean exit.

Mini Project: Application Health Checker

Create a script that checks multiple aspects of application health:

#!/bin/bash
# app_health.sh — Multi-check application health monitor
# Usage: ./app_health.sh

set -euo pipefail

readonly SCRIPT_NAME=$(basename "$0")
readonly ALERT_EMAIL="ops@dodatech.com"
readonly THRESHOLD_DISK=90  # Percent
readonly THRESHOLD_MEM=90   # Percent

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color

check_pass() { echo -e "${GREEN}PASS${NC} $1"; }
check_fail() { echo -e "${RED}FAIL${NC} $1"; }

# HTTP check
check_http() {
    local url="$1"
    local expected="${2:-200}"

    local status
    status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || echo "000")

    if [ "$status" = "$expected" ]; then
        check_pass "HTTP $url$status"
        return 0
    else
        check_fail "HTTP $url$status (expected $expected)"
        return 1
    fi
}

# Disk check
check_disk() {
    local usage
    usage=$(df / | awk 'NR==2 {print $5}' | sed 's/%//')

    if [ "$usage" -lt "$THRESHOLD_DISK" ]; then
        check_pass "Disk usage: ${usage}%"
        return 0
    else
        check_fail "Disk usage: ${usage}% (threshold: ${THRESHOLD_DISK}%)"
        return 1
    fi
}

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

    if [ "$usage" -lt "$THRESHOLD_MEM" ]; then
        check_pass "Memory usage: ${usage}%"
        return 0
    else
        check_fail "Memory usage: ${usage}% (threshold: ${THRESHOLD_MEM}%)"
        return 1
    fi
}

# Process check
check_process() {
    local proc="$1"

    if pgrep -x "$proc" >/dev/null; then
        local count
        count=$(pgrep -c -x "$proc")
        check_pass "Process $proc: running ($count instances)"
        return 0
    else
        check_fail "Process $proc: NOT running"
        return 1
    fi
}

# Main
echo "=== Application Health Check ==="
echo "Time: $(date)"
echo ""

failures=0

check_http "http://localhost:80" 200 || ((failures++))
check_http "http://localhost:8080/health" 200 || ((failures++))
check_disk || ((failures++))
check_memory || ((failures++))
check_process "nginx" || ((failures++))
check_process "myapp" || ((failures++))

echo ""
if [ "$failures" -eq 0 ]; then
    echo "Result: ALL CHECKS PASSED"
    exit 0
else
    echo "Result: $failures check(s) FAILED"
    # Send alert
    # mail -s "Alert: $failures health checks failed" "$ALERT_EMAIL" < "$0"
    exit 1
fi

Expected output:

=== Application Health Check ===
Time: Sat Jun 20 10:00:00 UTC 2026
PASS HTTP http://localhost:80 → 200
PASS HTTP http://localhost:8080/health → 200
PASS Disk usage: 45%
PASS Memory usage: 62%
PASS Process nginx: running (2 instances)
PASS Process myapp: running (1 instances)
Result: ALL CHECKS PASSED

FAQ

Should I use bash or Python for scripting?
Use Bash for simple system tasks (file operations, process management, pipeline commands). Use Python for complex logic, data processing, API interactions, or cross-platform needs.
How do I debug a script that works on one system but not another?
Differences in Bash version, installed tools, and environment variables are common causes. Add set -x and compare outputs. Use #!/usr/bin/env bash for portability.
What’s the best way to handle temporary files?
Create temp files with mktemp: tmpfile=$(mktemp). Clean up with trap 'rm -f "$tmpfile"' EXIT. This ensures cleanup even if the script crashes.
How do I pass arrays to functions?
Bash can’t pass arrays by reference natively. Use "${array[@]}" and reassign in the function: local args=("$@"). In Bash 4.3+, use local -n ref=$1.
What does || true do?
command || true forces the exit code to 0, preventing set -e from aborting. Use it when a command’s failure is acceptable.
How do I make a script run at startup?
Place it in /etc/rc.local, create a systemd service, or add it to crontab with @reboot. Systemd is the modern approach.

What’s Next

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Updated 2026-06-20.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro