Shell scripting fundamentals
In this series (15 parts)
- What is Linux and how it differs from other OSes
- Installing Linux and setting up your environment
- The Linux filesystem explained
- Users, groups, and permissions
- Essential command line tools
- Shell scripting fundamentals
- Processes and job control
- Standard I/O, pipes, and redirection
- The Linux networking stack
- Package management and software installation
- Disk management and filesystems
- Logs and system monitoring
- SSH and remote access
- Cron jobs and task scheduling
- Linux security basics for sysadmins
A shell script is a text file containing commands that the shell executes in order. Instead of typing the same 10 commands every morning, you write them once and run the script. This is the foundation of automation on Linux, and every sysadmin and developer needs to know it.
Prerequisites
You should be comfortable with essential command line tools before writing scripts that use them.
The basics
Creating and running a script
# Create a script file
cat > hello.sh << 'EOF'
#!/bin/bash
echo "Hello from a shell script"
echo "Today is $(date +%Y-%m-%d)"
echo "You are logged in as: $USER"
EOF
# Make it executable
chmod +x hello.sh
# Run it
./hello.sh
Output:
Hello from a shell script
Today is 2026-05-04
You are logged in as: pratik
The #!/bin/bash on line 1 is the shebang. It tells the system which interpreter to use. Without it, the system might use /bin/sh, which lacks some Bash features.
Variables
Variables hold strings by default. No spaces around the = sign.
#!/bin/bash
# Assignment (no spaces around =)
name="pratik"
count=42
today=$(date +%Y-%m-%d)
# Usage (prefix with $)
echo "Name: $name"
echo "Count: $count"
echo "Date: $today"
# Curly braces for clarity
echo "File: ${name}_backup.tar.gz"
Output:
Name: pratik
Count: 42
Date: 2026-05-04
File: pratik_backup.tar.gz
Special variables:
| Variable | Meaning |
|---|---|
$0 | Script name |
$1, $2, … | Positional arguments |
$# | Number of arguments |
$@ | All arguments as separate words |
$? | Exit code of the last command |
$$ | Process ID of the script |
$! | PID of the last background process |
Conditionals
#!/bin/bash
file="/etc/hostname"
# if/elif/else
if [ -f "$file" ]; then
echo "$file exists and is a regular file"
elif [ -d "$file" ]; then
echo "$file is a directory"
else
echo "$file does not exist"
fi
Common test operators:
| Test | Meaning |
|---|---|
-f file | File exists and is regular |
-d dir | Directory exists |
-r file | File is readable |
-w file | File is writable |
-x file | File is executable |
-z "$var" | Variable is empty |
-n "$var" | Variable is not empty |
"$a" = "$b" | String equality |
"$a" != "$b" | String inequality |
$a -eq $b | Numeric equality |
$a -gt $b | Greater than |
$a -lt $b | Less than |
For more complex conditions, use [[ ]] (Bash-specific, supports &&, ||, regex):
if [[ "$name" == "pratik" && $count -gt 10 ]]; then
echo "Match"
fi
Loops
For loop:
#!/bin/bash
# Iterate over a list
for fruit in apple banana cherry; do
echo "I like $fruit"
done
# Iterate over files
for file in /etc/*.conf; do
echo "Config file: $file"
done
# C-style for loop
for ((i=1; i<=5; i++)); do
echo "Iteration $i"
done
While loop:
#!/bin/bash
count=1
while [ $count -le 5 ]; do
echo "Count: $count"
((count++))
done
# Read a file line by line
while IFS= read -r line; do
echo "Line: $line"
done < /etc/hostname
Functions
#!/bin/bash
# Define a function
greet() {
local name="$1" # local variable
local time_of_day="$2"
echo "Good $time_of_day, $name!"
}
# Call it
greet "Pratik" "morning"
greet "World" "evening"
Output:
Good morning, Pratik!
Good evening, World!
Use local for variables inside functions to avoid polluting the global scope.
Functions can return exit codes (0-255) with return:
is_root() {
if [ "$(id -u)" -eq 0 ]; then
return 0 # success (true)
else
return 1 # failure (false)
fi
}
if is_root; then
echo "Running as root"
else
echo "Not root"
fi
Exit codes
Every command returns an exit code. 0 means success, anything else means failure.
#!/bin/bash
ls /etc/hostname
echo "Exit code: $?" # 0 (success)
ls /nonexistent
echo "Exit code: $?" # 2 (no such file)
Your scripts should set exit codes too:
#!/bin/bash
if [ $# -eq 0 ]; then
echo "Usage: $0 <filename>" >&2
exit 1
fi
if [ ! -f "$1" ]; then
echo "Error: $1 is not a file" >&2
exit 2
fi
echo "Processing $1..."
exit 0
Input and output
Reading user input:
#!/bin/bash
read -p "Enter your name: " name
echo "Hello, $name"
# Read with a timeout (5 seconds)
read -t 5 -p "Quick, enter a number: " num
echo "You entered: $num"
# Read a password (hidden input)
read -s -p "Enter password: " password
echo ""
echo "Password length: ${#password}"
Here-documents let you embed multi-line text:
cat << EOF
This is a multi-line
text block. Variables are $USER expanded.
The date is $(date).
EOF
Use << 'EOF' (with quotes) to prevent variable expansion:
cat << 'EOF'
This $variable is NOT expanded.
Neither is $(this command).
EOF
Example 1: Disk usage monitor with alerts
This script checks disk usage on all mounted partitions and warns when any exceeds a threshold.
cat > disk-monitor.sh << 'SCRIPT'
#!/bin/bash
# Disk usage monitor
# Usage: ./disk-monitor.sh [threshold_percent]
THRESHOLD=${1:-80} # Default 80% if no argument given
LOG_FILE="/tmp/disk-monitor.log"
ALERT=false
echo "=== Disk Usage Report: $(date) ===" | tee "$LOG_FILE"
echo "Alert threshold: ${THRESHOLD}%" | tee -a "$LOG_FILE"
echo "" | tee -a "$LOG_FILE"
# Read df output, skip the header line
df -h --output=source,pcent,target | tail -n +2 | while IFS= read -r line; do
# Extract the usage percentage (remove the % sign)
usage=$(echo "$line" | awk '{print $2}' | tr -d '%')
device=$(echo "$line" | awk '{print $1}')
mount=$(echo "$line" | awk '{print $3}')
# Skip virtual filesystems
case "$device" in
tmpfs|devtmpfs|udev|none) continue ;;
esac
if [ "$usage" -ge "$THRESHOLD" ] 2>/dev/null; then
echo "⚠ WARNING: $device mounted on $mount is at ${usage}%" | tee -a "$LOG_FILE"
ALERT=true
else
echo " OK: $device mounted on $mount is at ${usage}%" | tee -a "$LOG_FILE"
fi
done
echo "" | tee -a "$LOG_FILE"
echo "Report saved to $LOG_FILE"
SCRIPT
chmod +x disk-monitor.sh
Run it:
./disk-monitor.sh 50
Output:
=== Disk Usage Report: Sun May 4 10:00:00 UTC 2026 ===
Alert threshold: 50%
⚠ WARNING: /dev/sda2 mounted on / is at 67%
OK: /dev/sda1 mounted on /boot/efi is at 12%
Report saved to /tmp/disk-monitor.log
Run with a lower threshold to test the warning:
./disk-monitor.sh 10
To run this automatically, you can set it up as a cron job.
Example 2: Batch file renamer
This script renames files in a directory based on a pattern. It shows what it would do first (dry run), then asks for confirmation.
cat > batch-rename.sh << 'SCRIPT'
#!/bin/bash
# Batch file renamer
# Usage: ./batch-rename.sh <directory> <search> <replace>
set -e # Exit on any error
if [ $# -ne 3 ]; then
echo "Usage: $0 <directory> <search_pattern> <replace_string>"
echo "Example: $0 ./photos IMG_ vacation_"
exit 1
fi
DIR="$1"
SEARCH="$2"
REPLACE="$3"
if [ ! -d "$DIR" ]; then
echo "Error: $DIR is not a directory" >&2
exit 2
fi
# Collect files to rename
count=0
echo "=== Dry Run ==="
for file in "$DIR"/*"$SEARCH"*; do
[ -e "$file" ] || continue # Skip if no matches (glob didn't expand)
old_name=$(basename "$file")
new_name="${old_name//$SEARCH/$REPLACE}"
if [ "$old_name" != "$new_name" ]; then
echo " $old_name -> $new_name"
((count++))
fi
done
if [ "$count" -eq 0 ]; then
echo "No files match the pattern '$SEARCH' in $DIR"
exit 0
fi
echo ""
echo "$count file(s) would be renamed."
read -p "Proceed? (y/n): " confirm
if [ "$confirm" != "y" ]; then
echo "Cancelled."
exit 0
fi
# Perform the rename
echo ""
echo "=== Renaming ==="
for file in "$DIR"/*"$SEARCH"*; do
[ -e "$file" ] || continue
old_name=$(basename "$file")
new_name="${old_name//$SEARCH/$REPLACE}"
dir_path=$(dirname "$file")
if [ "$old_name" != "$new_name" ]; then
mv "$file" "$dir_path/$new_name"
echo " Renamed: $old_name -> $new_name"
fi
done
echo ""
echo "Done."
SCRIPT
chmod +x batch-rename.sh
Test it:
# Create test files
mkdir -p /tmp/rename-test
touch /tmp/rename-test/IMG_001.jpg
touch /tmp/rename-test/IMG_002.jpg
touch /tmp/rename-test/IMG_003.jpg
touch /tmp/rename-test/README.txt
# Run the renamer
./batch-rename.sh /tmp/rename-test IMG_ vacation_
Output:
=== Dry Run ===
IMG_001.jpg -> vacation_001.jpg
IMG_002.jpg -> vacation_002.jpg
IMG_003.jpg -> vacation_003.jpg
3 file(s) would be renamed.
Proceed? (y/n): y
=== Renaming ===
Renamed: IMG_001.jpg -> vacation_001.jpg
Renamed: IMG_002.jpg -> vacation_002.jpg
Renamed: IMG_003.jpg -> vacation_003.jpg
Done.
# Verify
ls /tmp/rename-test/
Output:
README.txt vacation_001.jpg vacation_002.jpg vacation_003.jpg
Notice that README.txt was not touched because it did not match the pattern.
Clean up:
rm -rf /tmp/rename-test
rm -f disk-monitor.sh batch-rename.sh hello.sh
Debugging tips
- Add
set -xat the top to print every command before it runs - Add
set -eto exit on any error - Add
set -uto treat unset variables as errors - Use
shellcheckto lint your scripts:shellcheck script.sh
#!/bin/bash
set -euo pipefail # Strict mode: exit on error, unset var, pipe failure
What comes next
Now that you can write scripts, the next article explains Processes and job control, covering how Linux manages running programs, signals, and the init system.
For a deeper understanding of how scripts interact with input and output streams, see Standard I/O, pipes, and redirection.