🏠 Home / Hub

Linux 07 — Shell Scripting

Shell scripting turns sequences of commands into reusable, automatable programs. A bash script can do anything you can type in a terminal — and do it reliably, consistently, at scheduled times, or in response to system events.

1. Getting Started: Shebang and Permissions

#!/bin/bash
# This is a comment. The first line (shebang) tells the OS which interpreter to use.

echo "Hello, World!"
# Save script, make executable, run it
$ nano myscript.sh               # create script
$ chmod +x myscript.sh           # make executable
$ ./myscript.sh                  # run it

# Alternative: run without execute permission
$ bash myscript.sh               # pass to bash directly
$ sh myscript.sh                 # use /bin/sh (POSIX shell, fewer features)

# Check script for syntax errors without running
$ bash -n myscript.sh            # check syntax only
$ bash -x myscript.sh            # trace execution (debug mode)

# Common shebangs
#!/bin/bash          # bash (most common for Linux scripts)
#!/bin/sh            # POSIX sh (more portable, fewer features)
#!/usr/bin/env bash  # find bash in PATH (better for portability)
#!/usr/bin/env python3  # Python script

2. Variables

#!/bin/bash

# Variable assignment (NO spaces around =)
NAME="Alice"
AGE=30
GREETING="Hello, $NAME"          # variable expansion in double quotes
LITERAL='Hello, $NAME'           # no expansion in single quotes

# Print variables
echo $NAME
echo ${NAME}                     # explicit braces (preferred)
echo "Name: ${NAME}, Age: ${AGE}"

# Read-only variables
readonly PI=3.14159
PI=3                             # error: cannot reassign

# Unset (delete) a variable
unset NAME
echo $NAME                       # prints nothing

# Variable types (bash is untyped, but declare adds attributes)
declare -i COUNT=10              # integer
declare -r MAX=100               # read-only
declare -a ARRAY=("a" "b" "c")   # indexed array
declare -A MAP                   # associative array (hash)
declare -l lower="HELLO"         # automatically lowercased
declare -u upper="hello"         # automatically uppercased

# Check if variable is set
if [ -z "${MY_VAR}" ]; then
    echo "MY_VAR is empty or unset"
fi

if [ -n "${MY_VAR}" ]; then
    echo "MY_VAR has a value: ${MY_VAR}"
fi

3. Command Substitution and Arithmetic

# Command substitution — capture output of a command
TODAY=$(date +%Y-%m-%d)           # modern syntax (preferred)
TODAY=`date +%Y-%m-%d`            # old syntax (backticks)

echo "Today is: ${TODAY}"
echo "Kernel: $(uname -r)"
echo "Current user: $(whoami)"
echo "Files here: $(ls | wc -l)"

# Assign command output
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}')
echo "Root disk usage: ${DISK_USAGE}"

# Arithmetic — use $(( )) for integer math
COUNT=5
DOUBLED=$((COUNT * 2))
echo $DOUBLED                     # 10

SUM=$((3 + 7))
DIFF=$((10 - 4))
PRODUCT=$((6 * 7))
QUOTIENT=$((20 / 3))              # integer division
REMAINDER=$((20 % 3))             # modulo
POWER=$((2 ** 10))                # exponentiation

# Increment/decrement
COUNT=0
((COUNT++))                       # increment
((COUNT--))                       # decrement
((COUNT += 5))                    # add 5
((COUNT *= 2))                    # multiply

# let command
let "RESULT = 5 * 6"
let "RESULT++"

# expr (old, prefer $(( )))
RESULT=$(expr 5 + 3)

# Floating point — bash doesn't support it natively, use bc
PI=$(echo "scale=4; 22/7" | bc)
echo $PI                          # 3.1428

4. String Operations

#!/bin/bash
STR="Hello, World!"

# String length
echo ${#STR}                     # 13

# Substring: ${var:start:length}
echo ${STR:0:5}                  # Hello
echo ${STR:7}                    # World!  (from position 7)
echo ${STR: -6}                  # orld!   (from end)

# Find and replace
FILENAME="report_2024.txt"
echo ${FILENAME/2024/2025}       # replace first match
echo ${FILENAME//0/O}            # replace all 0s with O (global)
echo ${FILENAME/.txt/.bak}       # change extension

# Remove prefix/suffix patterns
PATH_FILE="/home/alice/docs/report.txt"
echo ${PATH_FILE##*/}            # filename: report.txt (remove longest prefix */)
echo ${PATH_FILE%/*}             # directory: /home/alice/docs (remove shortest suffix /*)
echo ${PATH_FILE#*/}             # remove shortest prefix */
echo ${FILENAME%.txt}            # report_2024 (remove .txt suffix)

# Default values
NAME=""
echo ${NAME:-"Anonymous"}        # "Anonymous" if NAME is empty
echo ${NAME:="Anonymous"}        # same + assigns if empty

REQUIRED=${1:?"Error: argument required"}  # exit with error if not set

# Uppercase/lowercase (bash 4+)
STR="hello world"
echo ${STR^^}                    # HELLO WORLD (all uppercase)
echo ${STR,,}                    # hello world (all lowercase)
echo ${STR^}                     # Hello world (first char uppercase)

# String comparison
if [ "$STR1" = "$STR2" ]; then   # equal
if [ "$STR1" != "$STR2" ]; then  # not equal
if [[ "$STR" == *"hello"* ]]; then  # contains (glob pattern)

5. Conditionals — if/elif/else

#!/bin/bash

# Basic if syntax
if [ condition ]; then
    commands
elif [ other_condition ]; then
    commands
else
    commands
fi

# File tests
if [ -f /etc/passwd ]; then echo "file exists"; fi
if [ -d /etc/nginx ]; then echo "directory exists"; fi
if [ -e /path ]; then echo "path exists (any type)"; fi
if [ -r /etc/passwd ]; then echo "readable"; fi
if [ -w /tmp/test ]; then echo "writable"; fi
if [ -x /usr/bin/python3 ]; then echo "executable"; fi
if [ -s file.txt ]; then echo "non-empty file"; fi
if [ -L /usr/bin/python ]; then echo "is a symlink"; fi

# String tests
if [ -z "$VAR" ]; then echo "empty string"; fi
if [ -n "$VAR" ]; then echo "non-empty string"; fi
if [ "$A" = "$B" ]; then echo "strings equal"; fi
if [ "$A" != "$B" ]; then echo "strings not equal"; fi

# Numeric tests (use -eq, -ne, -lt, -le, -gt, -ge)
if [ "$NUM" -eq 0 ]; then echo "zero"; fi
if [ "$NUM" -ne 0 ]; then echo "non-zero"; fi
if [ "$NUM" -lt 10 ]; then echo "less than 10"; fi
if [ "$NUM" -gt 100 ]; then echo "greater than 100"; fi
if [ "$NUM" -ge 5 ] && [ "$NUM" -le 15 ]; then echo "5-15"; fi

# Practical example
#!/bin/bash
CPU_LOAD=$(uptime | awk -F'average:' '{print $2}' | awk -F',' '{print $1}' | tr -d ' ')
THRESHOLD=2

if (( $(echo "$CPU_LOAD > $THRESHOLD" | bc -l) )); then
    echo "ALERT: High CPU load: $CPU_LOAD"
else
    echo "CPU load normal: $CPU_LOAD"
fi

[[ ]] vs [ ] — Extended Test

# [[ ]] is a bash built-in — more powerful than [ ]
# Supports: &&, ||, regex =~, no word splitting

# Pattern matching with [[
if [[ "$FILENAME" == *.log ]]; then echo "it's a log file"; fi
if [[ "$FILENAME" == *.{txt,md} ]]; then echo "text file"; fi

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

# Compound conditions
if [[ -f "$FILE" && -r "$FILE" ]]; then
    echo "file exists and is readable"
fi

if [[ -z "$A" || -z "$B" ]]; then
    echo "at least one is empty"
fi

6. case Statement

#!/bin/bash

# case is cleaner than if/elif chains for multiple values
read -p "Enter choice (start/stop/status/restart): " ACTION

case "$ACTION" in
    start)
        echo "Starting service..."
        systemctl start myservice
        ;;
    stop)
        echo "Stopping service..."
        systemctl stop myservice
        ;;
    status)
        systemctl status myservice
        ;;
    restart | reload)          # match either
        systemctl restart myservice
        ;;
    [0-9]*)                    # match numbers
        echo "You entered a number"
        ;;
    *)                         # default case
        echo "Unknown action: $ACTION"
        echo "Usage: $0 start|stop|status|restart"
        exit 1
        ;;
esac

# case with file extension
FILENAME="document.pdf"
case "${FILENAME##*.}" in    # get extension
    txt|md)   echo "Text file" ;;
    pdf)      echo "PDF document" ;;
    jpg|png)  echo "Image file" ;;
    sh|bash)  echo "Shell script" ;;
    *)        echo "Unknown type" ;;
esac

7. Loops

#!/bin/bash

# for loop — iterate over a list
for item in apple banana cherry; do
    echo "Fruit: $item"
done

# for loop — numeric range
for i in {1..10}; do
    echo "Number: $i"
done

for i in {0..100..5}; do      # 0, 5, 10, 15, ..., 100
    echo $i
done

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

# for loop — iterate over files
for file in /var/log/*.log; do
    echo "Processing: $file"
    wc -l "$file"
done

# for loop — iterate over command output
for user in $(cut -d: -f1 /etc/passwd); do
    echo "User: $user"
done

# while loop — repeat while condition is true
COUNT=0
while [ $COUNT -lt 5 ]; do
    echo "Count: $COUNT"
    ((COUNT++))
done

# while loop — read file line by line (best practice)
while IFS= read -r line; do
    echo "Line: $line"
done < /etc/hosts

# while loop — read from command output
ps aux | while read -r line; do
    echo "$line"
done

# until loop — opposite of while (repeat UNTIL condition is true)
COUNT=10
until [ $COUNT -le 0 ]; do
    echo "Countdown: $COUNT"
    ((COUNT--))
done

# Loop control
for i in {1..20}; do
    if [ $i -eq 5 ]; then
        continue           # skip to next iteration
    fi
    if [ $i -eq 10 ]; then
        break              # exit loop
    fi
    echo $i
done

8. Functions

#!/bin/bash

# Two syntaxes for defining functions
function greet {
    echo "Hello, $1!"
}

greet_user() {
    echo "Hello, $1! You are $2 years old."
}

# Call functions
greet "Alice"
greet_user "Bob" 25

# Local variables (scope within function only)
my_function() {
    local local_var="I'm local"
    global_var="I'm global"
    echo "$local_var"
}
my_function
echo "$global_var"       # works
echo "$local_var"        # empty — not accessible outside

# Return values
# bash functions return exit codes (0=success, 1-255=error)
# To return a value, use echo + command substitution

get_timestamp() {
    echo $(date +%Y%m%d_%H%M%S)   # "return" a value via echo
}

STAMP=$(get_timestamp)             # capture the "returned" value
echo "Timestamp: $STAMP"

# Return exit code
validate_email() {
    if [[ "$1" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
        return 0          # success
    else
        return 1          # failure
    fi
}

if validate_email "alice@example.com"; then
    echo "Email is valid"
else
    echo "Invalid email"
fi

9. Script Arguments and Input

#!/bin/bash
# Script arguments: $0=script name, $1=first arg, $2=second, etc.

echo "Script: $0"
echo "First arg: $1"
echo "Second arg: $2"
echo "All args: $@"              # all arguments as separate items
echo "All args: $*"              # all arguments as single string
echo "Argument count: $#"

# Example: ./script.sh alice /tmp output.txt
# $0 = ./script.sh
# $1 = alice
# $2 = /tmp
# $3 = output.txt
# $# = 3

# Check argument count
if [ $# -lt 2 ]; then
    echo "Usage: $0 <username> <directory>"
    exit 1
fi

# shift — remove first argument (shift all left)
while [ $# -gt 0 ]; do
    echo "Processing: $1"
    shift
done

# getopts — parse command-line flags
while getopts "u:d:v" opt; do
    case $opt in
        u) USER="$OPTARG" ;;     # -u alice
        d) DIR="$OPTARG" ;;      # -d /tmp
        v) VERBOSE=true ;;       # -v flag
        ?) echo "Invalid option"; exit 1 ;;
    esac
done

# Interactive input
read -p "Enter your name: " NAME
echo "Hello, $NAME!"

read -s -p "Enter password: " PASSWORD    # -s = silent (no echo)
echo                                       # newline after silent input

read -t 10 -p "Answer (10 sec timeout): " ANSWER  # timeout

read -n 1 -p "Press any key to continue..." KEY   # read 1 character

# Read multiple values on one line
read -p "Enter first and last name: " FIRST LAST
echo "First: $FIRST, Last: $LAST"

10. Exit Codes and Error Handling

#!/bin/bash

# $? holds exit code of last command (0=success, non-zero=error)
ls /etc/passwd
echo "Exit code: $?"             # 0

ls /nonexistent 2>/dev/null
echo "Exit code: $?"             # 2 (file not found)

# Exit your script
exit 0                           # success
exit 1                           # general error
exit 2                           # misuse of command
exit 127                         # command not found

# Check exit codes
if command; then
    echo "command succeeded"
else
    echo "command failed with code: $?"
fi

# Short-circuit operators
mkdir /tmp/mydir && echo "Directory created"   # && = only if first succeeds
mkdir /tmp/mydir || exit 1                      # || = only if first fails
command || { echo "failed"; exit 1; }           # run block on failure

# set -e — exit script if any command fails
#!/bin/bash
set -e                           # exit on error
set -u                           # exit on unset variable
set -o pipefail                  # catch errors in pipelines
set -euo pipefail                # all three (recommended for robust scripts)

# Example with error handling
#!/bin/bash
set -euo pipefail

trap 'echo "Error on line $LINENO"; exit 1' ERR  # trap errors
trap 'cleanup' EXIT                               # always run cleanup

cleanup() {
    echo "Cleaning up..."
    rm -f /tmp/tmpfile.$$
}

# $$ = current PID (useful for unique temp files)
TMPFILE="/tmp/myapp.$$"

11. Arrays

#!/bin/bash

# Indexed arrays
FRUITS=("apple" "banana" "cherry" "date")
declare -a COLORS

# Access elements
echo ${FRUITS[0]}                # apple (0-indexed)
echo ${FRUITS[1]}                # banana
echo ${FRUITS[-1]}               # date (last element)
echo ${FRUITS[@]}                # all elements
echo ${#FRUITS[@]}               # array length: 4
echo ${!FRUITS[@]}               # indices: 0 1 2 3

# Modify array
FRUITS[4]="elderberry"           # add element
FRUITS+=("fig")                  # append element(s)
unset FRUITS[1]                  # remove element (leaves gap)
FRUITS=("${FRUITS[@]/banana/}")  # remove by value

# Iterate array
for fruit in "${FRUITS[@]}"; do
    echo "Fruit: $fruit"
done

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

# Array slicing
echo "${FRUITS[@]:1:3}"          # 3 elements starting from index 1

# Associative arrays (hash maps) — bash 4+
declare -A USER_INFO
USER_INFO["name"]="Alice"
USER_INFO["age"]="30"
USER_INFO["email"]="alice@example.com"

echo ${USER_INFO["name"]}        # Alice
echo ${!USER_INFO[@]}            # list keys
echo ${USER_INFO[@]}             # list values

for key in "${!USER_INFO[@]}"; do
    echo "$key = ${USER_INFO[$key]}"
done

12. Heredoc

#!/bin/bash

# Heredoc — multi-line string
cat << EOF
This is a
multi-line string.
Variables expand: $USER
EOF

# Heredoc with indentation suppression (using -)
cat <<- EOF
    This text has leading tabs removed.
    Variables still expand: $HOME
EOF

# Heredoc without variable expansion (single quote the delimiter)
cat << 'EOF'
This text has NO variable expansion.
$USER will print literally: $USER
EOF

# Heredoc into a file
cat > /tmp/config.conf << EOF
server_name = myserver
port = 8080
user = $USER
log_level = info
EOF

# Heredoc to run multiple commands via SSH
ssh user@hostname << EOF
cd /var/www
git pull origin main
sudo systemctl reload nginx
EOF

13. Practical Scripts

Backup Script

#!/bin/bash
set -euo pipefail

# Configuration
BACKUP_SOURCE="/home/alice"
BACKUP_DEST="/backup"
REMOTE_HOST="backup-server.example.com"
REMOTE_USER="backupuser"
REMOTE_PATH="/backups/alice"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="backup_${DATE}.tar.gz"
LOG="/var/log/backup.log"

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

log "Starting backup of $BACKUP_SOURCE"

# Create local compressed backup
tar -czf "${BACKUP_DEST}/${BACKUP_NAME}" \
    --exclude="${BACKUP_SOURCE}/.cache" \
    --exclude="${BACKUP_SOURCE}/Downloads" \
    "$BACKUP_SOURCE"

log "Local backup created: ${BACKUP_NAME}"

# Sync to remote server
rsync -avz --delete \
    "${BACKUP_DEST}/${BACKUP_NAME}" \
    "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"

log "Backup synced to remote server"

# Keep only last 7 daily backups locally
find "$BACKUP_DEST" -name "backup_*.tar.gz" -mtime +7 -delete
log "Old backups cleaned up"

log "Backup completed successfully"

System Health Check Script

#!/bin/bash
# health_check.sh — Check system health and alert if issues found

ALERT_EMAIL="admin@example.com"
DISK_THRESHOLD=85        # alert if disk > 85%
MEM_THRESHOLD=90         # alert if memory > 90%
LOAD_THRESHOLD=4.0       # alert if 5-min load avg > 4.0

ISSUES=()

# Check disk usage
while IFS= read -r line; do
    USAGE=$(echo "$line" | awk '{print $5}' | tr -d '%')
    MOUNT=$(echo "$line" | awk '{print $6}')
    if [ "$USAGE" -gt "$DISK_THRESHOLD" ]; then
        ISSUES+=("DISK: ${MOUNT} at ${USAGE}%")
    fi
done < <(df -h | grep -v "^Filesystem\|tmpfs\|udev")

# Check memory usage
TOTAL=$(free | grep Mem | awk '{print $2}')
USED=$(free | grep Mem | awk '{print $3}')
MEM_PCT=$(( USED * 100 / TOTAL ))
if [ "$MEM_PCT" -gt "$MEM_THRESHOLD" ]; then
    ISSUES+=("MEMORY: ${MEM_PCT}% used")
fi

# Check load average (5-minute)
LOAD=$(uptime | awk -F'average:' '{print $2}' | awk -F',' '{print $2}' | tr -d ' ')
if (( $(echo "$LOAD > $LOAD_THRESHOLD" | bc -l) )); then
    ISSUES+=("LOAD: 5-min average is $LOAD")
fi

# Check for failed services
FAILED=$(systemctl --failed --no-legend | awk '{print $1}')
if [ -n "$FAILED" ]; then
    ISSUES+=("FAILED SERVICES: $FAILED")
fi

# Report
if [ ${#ISSUES[@]} -gt 0 ]; then
    REPORT="System Health Alert on $(hostname)\n\nIssues found:"
    for issue in "${ISSUES[@]}"; do
        REPORT="$REPORT\n  - $issue"
    done
    echo -e "$REPORT"
    # Uncomment to send email:
    # echo -e "$REPORT" | mail -s "Health Alert: $(hostname)" "$ALERT_EMAIL"
    exit 1
else
    echo "$(date): System healthy - no issues found"
fi

User Creation Script with Validation

#!/bin/bash
set -euo pipefail

# Validate arguments
if [ $# -ne 2 ]; then
    echo "Usage: $0 <username> <email>"
    exit 1
fi

USERNAME="$1"
EMAIL="$2"

# Validate username format
if ! [[ "$USERNAME" =~ ^[a-z][a-z0-9_]{2,29}$ ]]; then
    echo "Error: Username must be 3-30 chars, start with letter, only lowercase/numbers/underscore"
    exit 1
fi

# Validate email format
if ! [[ "$EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
    echo "Error: Invalid email format"
    exit 1
fi

# Check if user already exists
if id "$USERNAME" &>/dev/null; then
    echo "Error: User '$USERNAME' already exists"
    exit 1
fi

# Create user
useradd -m -s /bin/bash -c "$EMAIL" "$USERNAME"
echo "Created user: $USERNAME"

# Generate random password
TEMP_PASSWORD=$(tr -dc 'A-Za-z0-9!@#$%' < /dev/urandom | head -c 16)
echo "$USERNAME:$TEMP_PASSWORD" | chpasswd
echo "Temporary password set. User must change on first login."

# Force password change on first login
passwd -e "$USERNAME"

# Set up SSH directory
mkdir -p /home/"$USERNAME"/.ssh
chmod 700 /home/"$USERNAME"/.ssh
chown "$USERNAME":"$USERNAME" /home/"$USERNAME"/.ssh

echo "User $USERNAME created successfully"
echo "Email: $EMAIL"
echo "Temp password: $TEMP_PASSWORD"

📌 Study Checklist