Search…
Linux from Scratch · Part 6

Shell scripting fundamentals

In this series (15 parts)
  1. What is Linux and how it differs from other OSes
  2. Installing Linux and setting up your environment
  3. The Linux filesystem explained
  4. Users, groups, and permissions
  5. Essential command line tools
  6. Shell scripting fundamentals
  7. Processes and job control
  8. Standard I/O, pipes, and redirection
  9. The Linux networking stack
  10. Package management and software installation
  11. Disk management and filesystems
  12. Logs and system monitoring
  13. SSH and remote access
  14. Cron jobs and task scheduling
  15. 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:

VariableMeaning
$0Script 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:

TestMeaning
-f fileFile exists and is regular
-d dirDirectory exists
-r fileFile is readable
-w fileFile is writable
-x fileFile is executable
-z "$var"Variable is empty
-n "$var"Variable is not empty
"$a" = "$b"String equality
"$a" != "$b"String inequality
$a -eq $bNumeric equality
$a -gt $bGreater than
$a -lt $bLess 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 -x at the top to print every command before it runs
  • Add set -e to exit on any error
  • Add set -u to treat unset variables as errors
  • Use shellcheck to 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.

Start typing to search across all content
navigate Enter open Esc close