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.
#!/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
#!/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
# 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
#!/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)
#!/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
# [[ ]] 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
#!/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
#!/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
#!/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
#!/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"
#!/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.$$"
#!/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
#!/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
#!/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"
#!/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
#!/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"