Bash scripts automate everything from deployments to backups. Here’s how to write them properly.

Script Structure

1
2
3
4
#!/bin/bash
set -euo pipefail

# Your code here

The shebang (#!/bin/bash) tells the system which interpreter to use. The set options make scripts safer:

  • -e: Exit on error
  • -u: Error on undefined variables
  • -o pipefail: Catch errors in pipelines

Variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Assignment (no spaces!)
NAME="Alice"
COUNT=42

# Usage
echo "Hello, $NAME"
echo "Count is ${COUNT}"

# Command substitution
DATE=$(date +%Y-%m-%d)
FILES=$(ls -1 | wc -l)

# Default values
ENVIRONMENT=${1:-production}    # First arg, default "production"
LOG_LEVEL=${LOG_LEVEL:-info}    # Env var with default

Variable Scope

1
2
3
4
5
6
7
8
# Global by default
GLOBAL="I'm everywhere"

# Local to function
my_function() {
    local LOCAL="I'm only here"
    echo "$LOCAL"
}

Conditionals

 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
# String comparison
if [ "$NAME" = "Alice" ]; then
    echo "Hi Alice"
elif [ "$NAME" = "Bob" ]; then
    echo "Hi Bob"
else
    echo "Who are you?"
fi

# Numeric comparison
if [ "$COUNT" -gt 10 ]; then
    echo "More than 10"
fi

# File tests
if [ -f "$FILE" ]; then
    echo "File exists"
fi

if [ -d "$DIR" ]; then
    echo "Directory exists"
fi

if [ -z "$VAR" ]; then
    echo "Variable is empty"
fi

if [ -n "$VAR" ]; then
    echo "Variable is not empty"
fi

Comparison Operators

StringNumericMeaning
=-eqEqual
!=-neNot equal
-ltLess than
-leLess or equal
-gtGreater than
-geGreater or equal

Modern Syntax

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Double brackets (safer, more features)
if [[ "$NAME" == "Alice" ]]; then
    echo "Hi"
fi

# Pattern matching
if [[ "$FILE" == *.txt ]]; then
    echo "Text file"
fi

# Regex
if [[ "$EMAIL" =~ ^[a-z]+@[a-z]+\.[a-z]+$ ]]; then
    echo "Valid email"
fi

# Logical operators
if [[ "$A" == "yes" && "$B" == "yes" ]]; then
    echo "Both yes"
fi

if [[ "$A" == "yes" || "$B" == "yes" ]]; then
    echo "At least one yes"
fi

Loops

 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
# For loop over list
for NAME in Alice Bob Charlie; do
    echo "Hello, $NAME"
done

# For loop over files
for FILE in *.txt; do
    echo "Processing $FILE"
done

# For loop with range
for i in {1..10}; do
    echo "Number $i"
done

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

# While loop
while [ "$COUNT" -gt 0 ]; do
    echo "$COUNT"
    COUNT=$((COUNT - 1))
done

# Read lines from file
while IFS= read -r line; do
    echo "Line: $line"
done < file.txt

# Read from command
while read -r file; do
    echo "Found: $file"
done < <(find . -name "*.log")

Functions

 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
# Basic function
greet() {
    echo "Hello, $1"
}

greet "World"

# With local variables
calculate() {
    local a=$1
    local b=$2
    echo $((a + b))
}

result=$(calculate 5 3)
echo "Result: $result"

# Return values (0 = success, non-zero = failure)
is_valid() {
    if [[ "$1" =~ ^[0-9]+$ ]]; then
        return 0
    else
        return 1
    fi
}

if is_valid "42"; then
    echo "Valid number"
fi

Arguments

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash

# Positional arguments
echo "Script: $0"
echo "First arg: $1"
echo "Second arg: $2"
echo "All args: $@"
echo "Arg count: $#"

# Shift through args
while [ $# -gt 0 ]; do
    echo "Arg: $1"
    shift
done

# Getopts for flags
while getopts "vf:o:" opt; do
    case $opt in
        v) VERBOSE=true ;;
        f) FILE="$OPTARG" ;;
        o) OUTPUT="$OPTARG" ;;
        ?) echo "Usage: $0 [-v] [-f file] [-o output]"; exit 1 ;;
    esac
done

Error Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash
set -euo pipefail

# Trap errors
trap 'echo "Error on line $LINENO"; exit 1' ERR

# Trap cleanup on exit
cleanup() {
    echo "Cleaning up..."
    rm -f "$TEMP_FILE"
}
trap cleanup EXIT

# Create temp file
TEMP_FILE=$(mktemp)

# Check command success
if ! command -v docker &> /dev/null; then
    echo "Docker not installed"
    exit 1
fi

# Or with ||
docker ps || { echo "Docker not running"; exit 1; }

Useful Patterns

Check if root

1
2
3
4
if [ "$EUID" -ne 0 ]; then
    echo "Please run as root"
    exit 1
fi

Confirm before proceeding

1
2
3
4
5
read -p "Are you sure? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    exit 1
fi

Logging

1
2
3
4
5
6
7
8
LOG_FILE="/var/log/myscript.log"

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

log "Starting script"
log "Task completed"

Config file

1
2
3
4
5
6
7
8
# config.sh
DB_HOST="localhost"
DB_PORT=5432
DB_NAME="myapp"

# main.sh
source ./config.sh
echo "Connecting to $DB_HOST:$DB_PORT"

Lock file (prevent concurrent runs)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
LOCKFILE="/tmp/myscript.lock"

if [ -f "$LOCKFILE" ]; then
    echo "Script already running"
    exit 1
fi

trap "rm -f $LOCKFILE" EXIT
touch "$LOCKFILE"

# Your script here

Arrays

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Define array
SERVERS=("web1" "web2" "web3")

# Access elements
echo "${SERVERS[0]}"      # First element
echo "${SERVERS[@]}"      # All elements
echo "${#SERVERS[@]}"     # Length

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

# Add element
SERVERS+=("web4")

# Associative array (bash 4+)
declare -A PORTS
PORTS[web]=80
PORTS[api]=8080
echo "${PORTS[web]}"

String Operations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
STR="Hello, World!"

# Length
echo "${#STR}"                    # 13

# Substring
echo "${STR:0:5}"                 # Hello

# Replace
echo "${STR/World/Bash}"          # Hello, Bash!

# Replace all
echo "${STR//o/0}"                # Hell0, W0rld!

# Remove prefix
FILE="document.txt"
echo "${FILE%.txt}"               # document

# Remove suffix
PATH="/home/user/file.txt"
echo "${PATH##*/}"                # file.txt
echo "${PATH%/*}"                 # /home/user

Real Script Example

 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
#!/bin/bash
set -euo pipefail

# Deploy script with logging and error handling

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="$SCRIPT_DIR/deploy.log"
ENVIRONMENT="${1:-staging}"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$ENVIRONMENT] $1" | tee -a "$LOG_FILE"
}

die() {
    log "ERROR: $1"
    exit 1
}

cleanup() {
    log "Cleanup complete"
}
trap cleanup EXIT

# Validate environment
if [[ ! "$ENVIRONMENT" =~ ^(staging|production)$ ]]; then
    die "Invalid environment: $ENVIRONMENT"
fi

# Production confirmation
if [ "$ENVIRONMENT" = "production" ]; then
    read -p "Deploy to PRODUCTION? (yes/no) " confirm
    [ "$confirm" = "yes" ] || die "Aborted"
fi

log "Starting deployment to $ENVIRONMENT"

# Pull latest code
log "Pulling latest code..."
git pull origin main || die "Git pull failed"

# Build
log "Building..."
npm run build || die "Build failed"

# Deploy
log "Deploying..."
rsync -avz ./dist/ "deploy@$ENVIRONMENT.example.com:/var/www/" || die "Deploy failed"

log "Deployment complete!"

Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Variables
VAR="value"
VAR=${VAR:-default}
VAR=$(command)

# Conditionals
[[ -f file ]]      # File exists
[[ -d dir ]]       # Dir exists
[[ -z "$var" ]]    # Empty
[[ "$a" == "$b" ]] # String equal
[[ $n -eq 5 ]]     # Numeric equal

# Loops
for i in list; do ...; done
while cond; do ...; done

# Functions
func() { local x=$1; echo $x; }

# Error handling
set -euo pipefail
trap 'cleanup' EXIT
command || { echo "failed"; exit 1; }

Bash isn’t pretty, but it’s everywhere. Master these patterns and you can automate anything.