Series: Shell Scripting for Beginners | Part: 2 of 2
Level: Beginner–Intermediate | Time to read: ~18 minutes
Part 1: Shell Scripting for Beginners: From Zero to Automating Your First Tasks
Quick Recap of Part 1
In Part 1, we covered the building blocks:
- Writing and running your first script (
chmod +x,./script.sh) - The shebang line (
#!/bin/bash) and what it does - Variables, curly brace syntax, and command substitution
- Passing arguments with
$1,$2,$# - Reading user input with
readandread -p - Arithmetic using
$(( ))
If any of that sounds unfamiliar, go through Part 1 first — everything in this article builds on it.
In Part 2, we're adding the logic layer. By the end of this, your scripts will be able to make decisions, repeat tasks, handle errors, and be structured enough that you'd actually want to maintain them.
Let's get into it.
Conditional Logic (if Statements)
A script that just runs commands top to bottom is useful. A script that can decide what to do based on conditions is powerful.
Basic if
#!/bin/bash
age=20
if [ $age -ge 18 ]; then
echo "You're an adult."
fi
The structure is always:
if [ condition ]; then
# commands to run if condition is true
fi
The spaces inside [ ] are not optional. [$age -ge 18] will fail. Always write [ $age -ge 18 ] with spaces.
if-else
#!/bin/bash
read -p "Enter your age: " age
if [ $age -ge 18 ]; then
echo "Access granted."
else
echo "Access denied. Come back in a few years."
fi
if-elif-else
#!/bin/bash
read -p "Enter your score: " score
if [ $score -ge 90 ]; then
echo "Grade: A"
elif [ $score -ge 75 ]; then
echo "Grade: B"
elif [ $score -ge 60 ]; then
echo "Grade: C"
else
echo "Grade: F — study harder."
fi
Comparison Operators
Bash uses different operators for numbers and strings — this trips people up constantly.
For numbers:
| Operator | Meaning |
|---|---|
-eq |
equal to |
-ne |
not equal to |
-gt |
greater than |
-lt |
less than |
-ge |
greater than or equal |
-le |
less than or equal |
For strings:
| Operator | Meaning |
|---|---|
= |
equal |
!= |
not equal |
-z |
is empty |
-n |
is not empty |
#!/bin/bash
name="Malhar"
if [ "$name" = "Malhar" ]; then
echo "Hey, I know you."
fi
if [ -z "$name" ]; then
echo "Name is empty."
else
echo "Name is: $name"
fi
For files and directories:
| Operator | Meaning |
|---|---|
-f |
file exists |
-d |
directory exists |
-e |
file or directory exists |
-r |
file is readable |
-w |
file is writable |
-x |
file is executable |
#!/bin/bash
if [ -f "/etc/passwd" ]; then
echo "File exists."
fi
if [ -d "/tmp" ]; then
echo "/tmp directory exists."
fi
Tip: Always quote your variables inside
[ ]. Write[ "$name" = "Malhar" ], not[ $name = "Malhar" ]. If$nameis empty or has spaces, the unquoted version breaks.
Double Brackets [[ ]]
Bash also supports [[ ]] — a more modern, forgiving syntax that handles edge cases better:
#!/bin/bash
name="Malhar Gupte"
# This works safely with spaces in the variable
if [[ $name == *"Malhar"* ]]; then
echo "Name contains Malhar"
fi
[[ ]] also supports && and || directly inside it, while [ ] doesn't. Once you're comfortable with the basics, prefer [[ ]] for new scripts.
For Loops
When you need to repeat something a fixed number of times or iterate over a list — that's a for loop.
Looping Through a List of Values
#!/bin/bash
for language in Python Bash Java Go Rust; do
echo "Language: $language"
done
Output:
Language: Python
Language: Bash
Language: Java
Language: Go
Language: Rust
Looping Through a Number Range
#!/bin/bash
for i in {1..5}; do
echo "Iteration: $i"
done
Or with a step:
for i in {0..20..5}; do
echo "$i" # 0, 5, 10, 15, 20
done
C-Style For Loop
#!/bin/bash
for ((i=1; i<=5; i++)); do
echo "Count: $i"
done
Looping Through Files
This is where for loops get genuinely useful:
#!/bin/bash
# Loop through all .log files in a directory
for file in /var/log/*.log; do
echo "Processing: $file"
echo "Size: $(du -sh "$file" | cut -f1)"
done
Practical Example: Batch Rename Files
#!/bin/bash
# Add a "backup_" prefix to all .txt files in current directory
for file in *.txt; do
mv "$file" "backup_${file}"
echo "Renamed: $file → backup_${file}"
done
Tip: Always test loops with
echofirst before running destructive commands likemv,rm, orcp. Replace the actual command withecho "would run: mv $file ..."and verify the output looks right.
While Loops
while loops keep running as long as a condition is true. They're perfect when you don't know in advance how many iterations you need.
Basic While Loop
#!/bin/bash
count=1
while [ $count -le 5 ]; do
echo "Count: $count"
((count++))
done
Output:
Count: 1
Count: 2
Count: 3
Count: 4
Count: 5
Reading a File Line by Line
One of the most common uses of while in real scripts:
#!/bin/bash
while IFS= read -r line; do
echo "Line: $line"
done < /etc/hosts
IFS= prevents leading/trailing whitespace from being stripped. -r prevents backslashes from being interpreted. Both are good habits.
Waiting for Something to Happen
#!/bin/bash
echo "Waiting for server to come online..."
while ! curl -s http://localhost:8080 > /dev/null; do
echo "Not ready yet. Retrying in 3 seconds..."
sleep 3
done
echo "Server is up!"
This kind of loop is legitimately useful in deployment scripts where you need to wait for a service to start before proceeding.
Tip: Always make sure your
whileloop has a way to end. A missing increment or a condition that never becomes false will give you an infinite loop that you'll have to kill withCtrl+C.
Case Statements
When you have one variable and multiple possible values to check against, case is far cleaner than a chain of if-elif statements.
The Problem case Solves
Imagine checking what day of the week it is:
# Verbose and repetitive with if-elif
if [ "$day" = "Monday" ]; then
...
elif [ "$day" = "Tuesday" ]; then
...
elif [ "$day" = "Wednesday" ]; then
...
With case:
#!/bin/bash
read -p "Enter day: " day
case $day in
Monday)
echo "Start of the week. Let's go." ;;
Tuesday | Wednesday | Thursday)
echo "Mid-week grind." ;;
Friday)
echo "Almost there." ;;
Saturday | Sunday)
echo "Weekend. Close the laptop." ;;
*)
echo "That's not a valid day." ;;
esac
Each pattern ends with ), the commands end with ;;, and *) is the catch-all default (like else). The block ends with esac — that's case backwards, a classic Unix move.
Practical Example: Script Menu
#!/bin/bash
echo "==============================="
echo " Server Management Menu"
echo "==============================="
echo "1) Check disk usage"
echo "2) Check memory usage"
echo "3) List running processes"
echo "4) Exit"
echo ""
read -p "Choose an option: " option
case $option in
1)
df -h ;;
2)
free -h ;;
3)
ps aux | head -20 ;;
4)
echo "Goodbye."
exit 0 ;;
*)
echo "Invalid option." ;;
esac
This kind of menu-driven script is a pattern you'll see all over real DevOps tooling.
Exit Codes
Every command you run in a terminal exits with a number. That number is the exit code, and it tells you whether the command succeeded or failed.
-
0= success - Any non-zero value = failure (the specific number often indicates the type of error)
Checking $?
$? holds the exit code of the last command that ran:
#!/bin/bash
ls /tmp
echo "Exit code: $?" # 0 — /tmp exists
ls /nonexistent_directory
echo "Exit code: $?" # 2 — directory not found
Using Exit Codes in Conditionals
#!/bin/bash
ping -c 1 google.com > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "Internet is up."
else
echo "No internet connection."
fi
Or more concisely — you can use the command directly in the if:
#!/bin/bash
if ping -c 1 google.com > /dev/null 2>&1; then
echo "Internet is up."
else
echo "No internet connection."
fi
Setting Exit Codes in Your Scripts
When writing scripts that other scripts or tools will call, always set meaningful exit codes:
#!/bin/bash
backup_file="/backups/db.sql"
if [ ! -f "$backup_file" ]; then
echo "ERROR: Backup file not found." >&2
exit 1
fi
echo "Backup file found. Proceeding..."
exit 0
>&2 redirects error messages to stderr instead of stdout — the right place for errors. exit 1 signals failure to whatever called your script.
Tip: In CI/CD pipelines, exit codes are everything. A script that exits with
0tells the pipeline "all good, move on." A non-zero exit code stops the pipeline and flags the step as failed. Always think about your exit codes when writing automation scripts.
Functions in Bash
Once your scripts get longer than 30-40 lines, repeating the same blocks of code starts to hurt. Functions let you name a block of code and call it whenever you need it.
Defining and Calling a Function
#!/bin/bash
greet() {
echo "Hello, $1!"
}
greet "Malhar"
greet "World"
Output:
Hello, Malhar!
Hello, World!
The function is defined first, then called by name. Arguments passed to the function are available as $1, $2, etc. — just like script arguments, but scoped to the function.
A More Realistic Function
#!/bin/bash
log() {
local level=$1
local message=$2
local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
echo "[$timestamp] [$level] $message"
}
log "INFO" "Script started."
log "INFO" "Checking disk usage..."
log "WARN" "Disk usage above 80%."
log "ERROR" "Backup directory not found."
Output:
[2025-03-10 10:42:01] [INFO] Script started.
[2025-03-10 10:42:01] [INFO] Checking disk usage...
[2025-03-10 10:42:01] [WARN] Disk usage above 80%.
[2025-03-10 10:42:01] [ERROR] Backup directory not found.
This log function is something you'd actually copy into real scripts.
local Variables
Notice the local keyword. Variables inside functions are global by default in Bash, which can cause bugs in longer scripts. Declaring them with local keeps them scoped to the function:
#!/bin/bash
count=10 # global
increment() {
local count=0 # local — doesn't touch the global $count
((count++))
echo "Inside function: $count"
}
increment
echo "Outside function: $count"
Output:
Inside function: 1
Outside function: 10
Functions with Return Values
Bash functions don't return values the way other languages do. They return exit codes (0-255). To "return" a value, you either echo it and capture it with $(), or use a global variable:
#!/bin/bash
get_disk_usage() {
local path=${1:-"/"}
df -h "$path" | awk 'NR==2 {print $5}' | tr -d '%'
}
usage=$(get_disk_usage "/")
echo "Disk usage on /: ${usage}%"
if [ "$usage" -gt 80 ]; then
echo "Warning: disk usage is high."
fi
Real Automation Script: System Health Check
Let's put everything together — conditionals, loops, functions, exit codes — into something you'd actually run on a server.
This script performs a basic system health check and prints a summary report.
#!/bin/bash
# ─────────────────────────────────────────────────────
# system_health_check.sh
# Checks disk, memory, CPU load, and running services
# Usage: ./system_health_check.sh
# ─────────────────────────────────────────────────────
# ── Thresholds ────────────────────────────────────────
DISK_THRESHOLD=80
MEM_THRESHOLD=85
SERVICES=("nginx" "ssh")
# ── Colors (optional but makes output readable) ───────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color (reset)
# ── Logging Function ──────────────────────────────────
log() {
local level=$1
local message=$2
local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
case $level in
INFO) echo -e "${GREEN}[$timestamp] [INFO]${NC} $message" ;;
WARN) echo -e "${YELLOW}[$timestamp] [WARN]${NC} $message" ;;
ERROR) echo -e "${RED}[$timestamp] [ERROR]${NC} $message" ;;
esac
}
# ── Check Disk Usage ──────────────────────────────────
check_disk() {
log "INFO" "Checking disk usage..."
while IFS= read -r line; do
usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
mount=$(echo "$line" | awk '{print $6}')
if [ "$usage" -ge "$DISK_THRESHOLD" ]; then
log "WARN" "Disk usage on $mount is at ${usage}% — above threshold of ${DISK_THRESHOLD}%"
else
log "INFO" "Disk usage on $mount: ${usage}% — OK"
fi
done < <(df -h | awk 'NR>1 && $1 ~ /^\/dev/')
}
# ── Check Memory Usage ────────────────────────────────
check_memory() {
log "INFO" "Checking memory usage..."
local total=$(free | awk '/^Mem:/ {print $2}')
local used=$(free | awk '/^Mem:/ {print $3}')
local usage=$(( used * 100 / total ))
if [ "$usage" -ge "$MEM_THRESHOLD" ]; then
log "WARN" "Memory usage is at ${usage}% — above threshold of ${MEM_THRESHOLD}%"
else
log "INFO" "Memory usage: ${usage}% — OK"
fi
}
# ── Check CPU Load ────────────────────────────────────
check_cpu() {
log "INFO" "Checking CPU load..."
local load=$(uptime | awk -F'load average:' '{print $2}' | awk -F',' '{print $1}' | xargs)
log "INFO" "1-minute load average: $load"
}
# ── Check Services ────────────────────────────────────
check_services() {
log "INFO" "Checking critical services..."
for service in "${SERVICES[@]}"; do
if systemctl is-active --quiet "$service" 2>/dev/null; then
log "INFO" "Service [$service] is running — OK"
else
log "ERROR" "Service [$service] is NOT running!"
fi
done
}
# ── Main ──────────────────────────────────────────────
main() {
echo ""
echo "=============================================="
echo " System Health Check"
echo " $(date)"
echo " Host: $(hostname)"
echo "=============================================="
echo ""
check_disk
echo ""
check_memory
echo ""
check_cpu
echo ""
check_services
echo ""
echo "=============================================="
echo " Health check complete."
echo "=============================================="
echo ""
}
main
exit 0
Make it executable and run it:
chmod +x system_health_check.sh
./system_health_check.sh
Sample output:
==============================================
System Health Check
Mon Mar 10 10:45:01 IST 2025
Host: dev-server
==============================================
[2025-03-10 10:45:01] [INFO] Checking disk usage...
[2025-03-10 10:45:01] [INFO] Disk usage on /: 43% — OK
[2025-03-10 10:45:01] [WARN] Disk usage on /data is at 87% — above threshold of 80%
[2025-03-10 10:45:01] [INFO] Checking memory usage...
[2025-03-10 10:45:01] [INFO] Memory usage: 61% — OK
[2025-03-10 10:45:01] [INFO] Checking CPU load...
[2025-03-10 10:45:01] [INFO] 1-minute load average: 0.42
[2025-03-10 10:45:01] [INFO] Checking critical services...
[2025-03-10 10:45:01] [INFO] Service [nginx] is running — OK
[2025-03-10 10:45:01] [ERROR] Service [ssh] is NOT running!
==============================================
Health check complete.
==============================================
This is the kind of script that runs daily via cron on real servers. Add an email or Slack notification at the end and you have a basic monitoring system.
Best Practices for Shell Scripts
Writing a script that works is one thing. Writing a script that someone else (or future you) can read, debug, and maintain is another.
Always Quote Your Variables
# Bad — breaks if filename has spaces
rm $filename
# Good
rm "$filename"
This is the single most common source of bugs in shell scripts. Filenames, paths, and user input can all contain spaces. Always quote.
Use Meaningful Names
# Hard to read
for f in *.log; do
s=$(wc -l < "$f")
echo "$f: $s"
done
# Clear
for log_file in *.log; do
line_count=$(wc -l < "$log_file")
echo "$log_file: $line_count lines"
done
Use set -e and set -u at the Top
#!/bin/bash
set -e # Exit immediately if any command fails
set -u # Treat unset variables as errors
set -e means your script won't silently continue after a failed command. set -u catches typos in variable names. Add these to every script you write seriously.
Use ShellCheck
ShellCheck is a free static analysis tool for shell scripts. Paste your script in and it'll catch bugs, bad practices, and portability issues before you even run the script.
You can also install it locally:
# Ubuntu/Debian
sudo apt install shellcheck
# Then run it on your script
shellcheck your_script.sh
It's the bash equivalent of a linter. Use it.
Add a Usage Comment at the Top
#!/bin/bash
# ─────────────────────────────────────────
# backup.sh
# Description: Backs up a directory to a target location
# Usage: ./backup.sh <source_dir> <target_dir>
# Author: Malhar Gupte
# Last updated: 2025-03-10
# ─────────────────────────────────────────
Takes 30 seconds and saves a lot of confusion when you revisit the script months later.
Redirect Error Messages to stderr
# Good practice for error messages
echo "ERROR: File not found." >&2
exit 1
This means error messages won't pollute the stdout output of your script, which matters when other scripts or pipelines consume your script's output.
Final Thoughts
If you've made it through both parts of this series, you can now write scripts that:
- Make decisions with
if,elif,case - Repeat tasks with
forandwhile - Handle success and failure with exit codes
- Stay organized with functions
- Check real system health on a live server
That's not beginner knowledge anymore.
For DevOps, shell scripting is the connective tissue between tools. It's how you glue together Docker commands, AWS CLI calls, database backups, log rotations, and deployment steps into a single automated workflow. Even if you work primarily with Python or Go for automation eventually, knowing how to read and write Bash means you can work on any server, in any environment, without needing anything installed.
The best way to get better at this is to find something you do manually and automate it. Clean up old files? Script it. SSH into a server and run the same three commands? Script it. Check if your services are running? You just wrote that script.
Start small. The scripts you write for yourself are the ones you'll actually learn from.
That's a wrap on this series. If something wasn't clear or you ran into an issue, drop a comment — happy to help debug. And if you're building something interesting with what you learned, share it.
Part 1 is here if you missed it: Shell Scripting for Beginners — Part 1
Top comments (0)