Real-World Shell Scripts

Summary: in this tutorial, you will learn build three practical bash scripts: a filename normalizer, a system health monitor, and an automatic file organizer.

Real-World Shell Scripts

You've learned the syntax, commands, and patterns. Now it's time to build something real. This tutorial presents complete, practical scripts that solve actual system administration and automation problems. Each demonstrates multiple concepts working together in production-quality code.

What makes these "real-world" scripts?

  • Robust error handling: They don't break on edge cases
  • User-friendly: Clear output, helpful usage messages
  • Maintainable: Well-structured, commented where needed
  • Tested patterns: Based on years of production experience
  • Immediately useful: You can deploy these today

Each script builds on the advanced techniques from previous tutorials: strict mode, logging, error handling, argument parsing, and cleanup. Study the patterns—they're your toolkit for any automation task.

1. Filename Normalizer — Taming Chaotic File Names

The Problem: Users create files like My Report (final) v2 FINAL.docx, breaking scripts that don't handle spaces and special characters. Web servers choke on filenames with spaces. Archives become a nightmare.

The Solution: Automatically normalize filenames to shell-friendly format:

  • Convert to lowercase
  • Replace spaces with underscores
  • Remove special characters
  • Eliminate consecutive underscores
  • Preserve file extensions
#!/usr/bin/env bash
set -euo pipefail
 
# normalize_filenames.sh — Rename files to be shell-friendly
#
# WHAT IT DOES:
#   - Converts spaces to underscores
#   - Removes special characters (keeps alphanumeric, dash, underscore)
#   - Converts to lowercase
#   - Eliminates consecutive underscores
#   - Preserves file extensions
#
# USAGE:
#   ./normalize_filenames.sh [DIRECTORY]
#
# EXAMPLES:
#   ./normalize_filenames.sh              # Normalize files in current directory
#   ./normalize_filenames.sh ~/Downloads  # Normalize files in ~/Downloads
#
# TRANSFORMATIONS:
#   "My Document (Final).pdf" → "my_document_final.pdf"
#   "Photo 2024-02-11.JPG"    → "photo_2024-02-11.jpg"
#   "Script___test.sh"        → "script_test.sh"
 
readonly SCRIPT_NAME="$(basename "$0")"
 
# Color output (only if terminal)
if [[ -t 1 ]]; then
    readonly GREEN='\033[0;32m'
    readonly YELLOW='\033[0;33m'
    readonly NC='\033[0m'
else
    readonly GREEN='' YELLOW='' NC=''
fi
 
log()  { echo -e "${GREEN}→${NC} $*"; }
skip() { echo -e "${YELLOW}⊗${NC} $*"; }
 
normalize_filename() {
    local file="$1"
    local dir base ext newname
 
    # Skip if not a regular file
    [[ -f "$file" ]] || return 0
 
    dir="$(dirname "$file")"
    base="$(basename "$file")"
 
    # Separate extension
    if [[ "$base" == *.* ]]; then
        ext=".${base##*.}"
        base="${base%.*}"
    else
        ext=""
    fi
 
    # Normalize the basename
    newname=$(echo "$base" | \
        tr '[:upper:]' '[:lower:]' |       # Lowercase
        tr ' ' '_' |                        # Spaces → underscores
        tr -cd '[:alnum:]_-' |             # Keep only alphanumeric, _, -
        sed 's/__*/_/g' |                  # Multiple _ → single _
        sed 's/^_//;s/_$//')               # Remove leading/trailing _
 
    # Add back extension (lowercased)
    ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
    newname="${newname}${ext}"
 
    # Rename if changed
    if [[ "$base${ext}" != "$newname" ]]; then
        if [[ -e "$dir/$newname" ]]; then
            skip "Skipping '$base${ext}' — '$newname' already exists"
        else
            mv "$file" "$dir/$newname"
            log "Renamed: '$base${ext}' → '$newname'"
        fi
    fi
}
 
usage() {
    cat << EOF
Usage: $SCRIPT_NAME [DIRECTORY]
 
Normalize filenames in DIRECTORY (default: current directory).
 
Transformations:
  - Convert to lowercase
  - Replace spaces with underscores
  - Remove special characters (keep alphanumeric, dash, underscore)
  - Eliminate consecutive underscores
  - Preserve file extensions
 
Examples:
  $SCRIPT_NAME                # Normalize current directory
  $SCRIPT_NAME ~/Downloads    # Normalize ~/Downloads
EOF
}
 
# Parse arguments
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
    usage
    exit 0
fi
 
target_dir="${1:-.}"
 
# Validate target directory
if [[ ! -d "$target_dir" ]]; then
    echo "Error: Directory not found: $target_dir" >&2
    exit 1
fi
 
# Process files (non-recursively, regular files only)
count=0
processed=0
 
while IFS= read -r -d '' file; do
    ((count++))
    old_name="$(basename "$file")"
    normalize_filename "$file"
    new_name="$(basename "$file" 2>/dev/null)" || new_name="$old_name"
    [[ "$old_name" != "$new_name" ]] && ((processed++))
done < <(find "$target_dir" -maxdepth 1 -type f -print0)
 
echo ""
echo "Processed $count files, normalized $processed filenames"
 

Why this script is production-ready:

  1. Handles all edge cases:
    • Files without extensions
    • Files with dots in the name (file.backup.tar.gz)
    • Files that already exist (doesn't overwrite)
    • Non-file objects (skips directories, symlinks)
  2. Safe transformations:
    • Uses tr -cd to remove characters (keeps only safe ones)
    • sed 's/__*/_/g' collapses multiple underscores
    • Preserves file extensions correctly
  3. User-friendly:
    • Shows what's being renamed
    • Skips conflicts instead of failing
    • Provides summary statistics

Real-world use cases:

  • Clean up Downloads folder before archiving
  • Prepare files for web upload (web servers hate spaces)
  • Standardize filenames in a photo library
  • Prepare files for version control (Git works better with simple names)

2. System Health Monitor — Know Before It Breaks

The Problem: You find out your server has been overloaded or out of disk space after things have failed. By then, it's crisis management instead of prevention.

The Solution: Automated health monitoring that checks CPU, memory, disk, and critical services. Run it via cron every 5 minutes, and you'll know about problems before users do.

#!/usr/bin/env bash
set -euo pipefail
 
# health_monitor.sh — Comprehensive system health checker
#
# USAGE:
#   ./health_monitor.sh [-w] [-e EMAIL] [-t THRESHOLDS]
#
# OPTIONS:
#   -w           Write to log file (/var/log/health_monitor.log)
#   -e EMAIL     Send alert email if issues found
#   -s           Silent mode (only output if problems detected)
#   -h           Show help
#
# THRESHOLDS:
#   Customize with environment variables:
#     WARN_CPU=80     CPU load warning (percentage)
#     WARN_MEM=85     Memory usage warning (percentage)
#     WARN_DISK=90    Disk usage warning (percentage)
#
# CRON EXAMPLE:
#   */5 * * * * /usr/local/bin/health_monitor.sh -w -e admin@example.com -s
 
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOGFILE="${LOGFILE:-/var/log/health_monitor.log}"
readonly WARN_CPU="${WARN_CPU:-80}"
readonly WARN_MEM="${WARN_MEM:-85}"
readonly WARN_DISK="${WARN_DISK:-90}"
 
# Options
write_log=false
email_alert=""
silent=false
 
# Track issues
declare -a issues=()
declare -a warnings=()
 
# Color output (only if terminal and not silent)
if [[ -t 1 ]]; then
    readonly RED='\033[0;31m'
    readonly GREEN='\033[0;32m'
    readonly YELLOW='\033[0;33m'
    readonly BLUE='\033[0;34m'
    readonly BOLD='\033[1m'
    readonly NC='\033[0m'
else
    readonly RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
fi
 
# Logging functions
log_msg() {
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
    $write_log && echo "$msg" >> "$LOGFILE"
    $silent || echo "$msg"
}
 
ok()   { log_msg "$(echo -e "${GREEN}[OK]${NC}   $*")"; }
warn() { log_msg "$(echo -e "${YELLOW}[WARN]${NC} $*")"; warnings+=("$*"); }
fail() { log_msg "$(echo -e "${RED}[FAIL]${NC} $*")"; issues+=("$*"); }
info() { log_msg "$(echo -e "${BLUE}[INFO]${NC} $*")"; }
 
usage() {
    cat << EOF
Usage: $SCRIPT_NAME [OPTIONS]
 
Monitor system health: CPU, memory, disk, services.
 
OPTIONS:
    -w              Write to log file ($LOGFILE)
    -e EMAIL        Send email alert if issues detected
    -s              Silent mode (only output if problems found)
    -h              Show this help
 
ENVIRONMENT:
    WARN_CPU=80     CPU load warning threshold (default: 80%)
    WARN_MEM=85     Memory usage warning (default: 85%)
    WARN_DISK=90    Disk usage warning (default: 90%)
 
EXAMPLES:
    $SCRIPT_NAME                           # Run interactively
    $SCRIPT_NAME -w                        # Log to file
    $SCRIPT_NAME -w -e ops@example.com -s  # Cron mode: log and email on issues
 
CRON:
    # Check every 5 minutes, log, email on issues
    */5 * * * * $SCRIPT_NAME -w -e admin@example.com -s
EOF
}
 
# Parse options
while getopts ":we:sh" opt; do
    case $opt in
        w) write_log=true ;;
        e) email_alert="$OPTARG" ;;
        s) silent=true ;;
        h) usage; exit 0 ;;
        \?) echo "Unknown option: -$OPTARG" >&2; usage; exit 1 ;;
        :) echo "-$OPTARG requires an argument" >&2; exit 1 ;;
    esac
done
 
# Header (skip if silent with no issues)
print_header() {
    $silent && return
    log_msg ""
    log_msg "=========================================="
    log_msg "  System Health Report"
    log_msg "  $(date)"
    log_msg "  Hostname: $(hostname)"
    log_msg "=========================================="
    log_msg ""
}
 
print_header
 
# ==================== Check CPU Load ====================
check_cpu() {
    local load cores load_pct
 
    # Get 1-minute load average
    if [[ -f /proc/loadavg ]]; then
        load=$(awk '{print $1}' /proc/loadavg)
    else
        load=$(uptime | awk -F'load average:' '{print $2}' | cut -d, -f1 | tr -d ' ')
    fi
 
    # Get number of CPU cores
    cores=$(nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo 2>/dev/null || echo 1)
 
    # Calculate load percentage
    load_pct=$(echo "$load $cores" | awk '{printf "%.0f", ($1/$2)*100}')
 
    if (( load_pct >= WARN_CPU )); then
        fail "CPU load: ${load_pct}% (load: $load, cores: $cores)"
    else
        ok "CPU load: ${load_pct}% (load: $load, cores: $cores)"
    fi
}
 
# ==================== Check Memory ====================
check_memory() {
    if ! command -v free &>/dev/null; then
        warn "Memory check skipped: 'free' command not available"
        return
    fi
 
    # Parse free output: total, used, available
    local mem_info
    mem_info=$(free | awk 'NR==2{printf "%d %d %d", $2, $3, $7}')
 
    local total used available mem_pct
    read -r total used available <<< "$mem_info"
 
    mem_pct=$((used * 100 / total))
 
    if (( mem_pct >= WARN_MEM )); then
        fail "Memory usage: ${mem_pct}% (${used}/${total} KB)"
    else
        ok "Memory usage: ${mem_pct}% (${used}/${total} KB)"
    fi
}
 
# ==================== Check Disk Space ====================
check_disk() {
    local found_issue=false
 
    while IFS= read -r line; do
        local usage mount
        usage=$(echo "$line" | awk '{print $1}')
        mount=$(echo "$line" | awk '{print $2}')
        usage_num="${usage%\%}"
 
        if (( usage_num >= WARN_DISK )); then
            fail "Disk $mount: ${usage}% full"
            found_issue=true
        else
            ok "Disk $mount: ${usage} used"
        fi
    done < <(df -h --output=pcent,target -x tmpfs -x devtmpfs 2>/dev/null | tail -n +2)
 
    # Fallback for systems without GNU coreutils
    if [[ ! -f /proc/mounts ]]; then
        df -h | tail -n +2 | while read -r _ _ _ _ usage mount _; do
            usage_num="${usage%\%}"
            if (( usage_num >= WARN_DISK )); then
                fail "Disk $mount: ${usage}% full"
            else
                ok "Disk $mount: ${usage} used"
            fi
        done
    fi
}
 
# ==================== Check Services ====================
check_service() {
    local service="$1"
 
    # Try multiple methods to check if service is running
    if pgrep -x "$service" >/dev/null 2>&1; then
        ok "Service $service: running"
    elif systemctl is-active --quiet "$service" 2>/dev/null; then
        ok "Service $service: running (systemd)"
    else
        warn "Service $service: not running"
    fi
}
 
check_services() {
    # Common critical services (adjust for your environment)
    local services=("sshd" "cron")
 
    # Add custom services from environment variable
    if [[ -n "${MONITOR_SERVICES:-}" ]]; then
        IFS=',' read -ra additional_services <<< "$MONITOR_SERVICES"
        services+=("${additional_services[@]}")
    fi
 
    for service in "${services[@]}"; do
        check_service "$service"
    done
}
 
# ==================== Check System Uptime ====================
check_uptime() {
    local uptime_str
    uptime_str=$(uptime -p 2>/dev/null || uptime | sed 's/.*up //' | sed 's/,.*//')
    info "System uptime: $uptime_str"
}
 
# ==================== Run All Checks ====================
check_cpu
check_memory
check_disk
check_services
check_uptime
 
# ==================== Summary ====================
print_summary() {
    $silent && [[ ${#issues[@]} -eq 0 && ${#warnings[@]} -eq 0 ]] && return
 
    log_msg ""
    log_msg "=========================================="
 
    if [[ ${#issues[@]} -eq 0 && ${#warnings[@]} -eq 0 ]]; then
        log_msg "$(echo -e "${GREEN}${BOLD}✓ All checks passed!${NC}")"
    else
        if [[ ${#issues[@]} -gt 0 ]]; then
            log_msg "$(echo -e "${RED}${BOLD}✗ ${#issues[@]} critical issue(s):${NC}")"
            for issue in "${issues[@]}"; do
                log_msg "  • $issue"
            done
        fi
        if [[ ${#warnings[@]} -gt 0 ]]; then
            log_msg "$(echo -e "${YELLOW}${BOLD}⚠ ${#warnings[@]} warning(s):${NC}")"
            for warning in "${warnings[@]}"; do
                log_msg "  • $warning"
            done
        fi
    fi
 
    log_msg "=========================================="
}
 
print_summary
 
# ==================== Email Alert ====================
send_alert() {
    [[ -z "$email_alert" ]] && return
    [[ ${#issues[@]} -eq 0 && ${#warnings[@]} -eq 0 ]] && return
 
    local subject="[ALERT] System Health Issues on $(hostname)"
    local body
 
    body="System: $(hostname)
Time: $(date)
 
"
 
    if [[ ${#issues[@]} -gt 0 ]]; then
        body+="CRITICAL ISSUES (${#issues[@]}):
"
        for issue in "${issues[@]}"; do
            body+="  - $issue
"
        done
        body+="
"
    fi
 
    if [[ ${#warnings[@]} -gt 0 ]]; then
        body+="WARNINGS (${#warnings[@]}):
"
        for warning in "${warnings[@]}"; do
            body+="  - $warning
"
        done
    fi
 
    if command -v mail &>/dev/null; then
        echo "$body" | mail -s "$subject" "$email_alert"
        info "Alert email sent to $email_alert"
    else
        warn "Cannot send email: 'mail' command not available"
    fi
}
 
send_alert
 
# Exit with error if critical issues found
[[ ${#issues[@]} -eq 0 ]] || exit 1
 

Why this is production-grade:

  1. Flexible deployment:
    • Interactive mode: See colorful output
    • Cron mode: -s (silent unless problems), logs to file, sends email
    • Customizable thresholds via environment variables
  2. Comprehensive checks:
    • CPU load as percentage of cores (8-core machine with load 4.0 is 50%)
    • Memory usage (actual used, not cached)
    • All mounted filesystems (skips tmpfs/devtmpfs)
    • Critical services (extensible via MONITOR_SERVICES)
  3. Robust detection:
    • Tries multiple methods to check service status (pgrep, systemctl)
    • Handles systems without GNU coreutils
    • Gracefully skips unavailable checks
  4. Actionable alerts:
    • Email only when problems detected
    • Clear separation: critical issues vs warnings
    • Exit code indicates problems (for automation)

Real-world deployment:

# Install
sudo cp health_monitor.sh /usr/local/bin/
sudo chmod +x /usr/local/bin/health_monitor.sh
 
# Cron: check every 5 minutes, log, email on issues
sudo crontab -e
*/5 * * * * WARN_CPU=90 WARN_MEM=90 WARN_DISK=95 /usr/local/bin/health_monitor.sh -w -e ops@example.com -s
 

3. File Organizer — Automatic Folder Structure

The Problem: Downloads folder is chaos. Hundreds of mixed files (PDFs, images, videos, code) all in one directory.

The Solution: Automatically sort files into category folders by extension.

#!/usr/bin/env bash
set -euo pipefail
 
# file_organizer.sh — Sort files into folders by type
#
# USAGE:
#   ./file_organizer.sh [DIRECTORY]
#
# EXAMPLES:
#   ./file_organizer.sh              # Organize current directory
#   ./file_organizer.sh ~/Downloads  # Organize Downloads
 
readonly SCRIPT_NAME="$(basename "$0")"
 
# Define file categories (extension → folder)
declare -A CATEGORIES=(
    # Images
    [jpg]="Images" [jpeg]="Images" [png]="Images" [gif]="Images"
    [svg]="Images" [bmp]="Images" [webp]="Images" [ico]="Images"
    [heic]="Images" [tiff]="Images" [raw]="Images"
 
    # Documents
    [pdf]="Documents" [doc]="Documents" [docx]="Documents"
    [txt]="Documents" [md]="Documents" [odt]="Documents"
    [xls]="Documents" [xlsx]="Documents" [csv]="Documents"
    [ppt]="Documents" [pptx]="Documents" [rtf]="Documents"
 
    # Videos
    [mp4]="Video" [mkv]="Video" [avi]="Video" [mov]="Video"
    [wmv]="Video" [flv]="Video" [webm]="Video" [m4v]="Video"
 
    # Audio
    [mp3]="Audio" [wav]="Audio" [flac]="Audio" [aac]="Audio"
    [ogg]="Audio" [wma]="Audio" [m4a]="Audio" [opus]="Audio"
 
    # Archives
    [zip]="Archives" [tar]="Archives" [gz]="Archives"
    [rar]="Archives" [7z]="Archives" [bz2]="Archives"
    [xz]="Archives" [tgz]="Archives"
 
    # Code
    [py]="Code" [js]="Code" [ts]="Code" [jsx]="Code" [tsx]="Code"
    [html]="Code" [css]="Code" [scss]="Code" [sass]="Code"
    [sh]="Code" [bash]="Code" [rb]="Code" [java]="Code"
    [c]="Code" [cpp]="Code" [h]="Code" [go]="Code"
    [rs]="Code" [php]="Code" [swift]="Code" [kt]="Code"
    [sql]="Code" [json]="Code" [xml]="Code" [yaml]="Code" [yml]="Code"
 
    # Executables
    [exe]="Executables" [msi]="Executables" [dmg]="Executables"
    [app]="Executables" [deb]="Executables" [rpm]="Executables"
)
 
# Color output
if [[ -t 1 ]]; then
    readonly GREEN='\033[0;32m'
    readonly YELLOW='\033[0;33m'
    readonly BLUE='\033[0;34m'
    readonly NC='\033[0m'
else
    readonly GREEN='' YELLOW='' BLUE='' NC=''
fi
 
moved=0
skipped=0
errors=0
 
organize_file() {
    local file="$1"
    local filename ext dest_dir
 
    filename=$(basename "$file")
 
    # Get extension (lowercase)
    if [[ "$filename" == *.* ]]; then
        ext="${filename##*.}"
        ext="${ext,,}"  # Lowercase
    else
        echo -e "${YELLOW}⊗${NC} Skipped (no extension): $filename"
        ((skipped++))
        return
    fi
 
    # Check if we have a category for this extension
    if [[ -n "${CATEGORIES[$ext]:-}" ]]; then
        dest_dir="$(dirname "$file")/${CATEGORIES[$ext]}"
 
        # Create destination directory
        if ! mkdir -p "$dest_dir"; then
            echo -e "${RED}✗${NC} Error creating directory: $dest_dir" >&2
            ((errors++))
            return
        fi
 
        # Check if destination file already exists
        if [[ -e "$dest_dir/$filename" ]]; then
            echo -e "${YELLOW}⊗${NC} Skipped (already exists): $filename → ${CATEGORIES[$ext]}/"
            ((skipped++))
            return
        fi
 
        # Move file
        if mv "$file" "$dest_dir/"; then
            echo -e "${GREEN}✓${NC} Moved: $filename → ${CATEGORIES[$ext]}/"
            ((moved++))
        else
            echo -e "${RED}✗${NC} Error moving: $filename" >&2
            ((errors++))
        fi
    else
        echo -e "${BLUE}⊗${NC} Skipped (unknown type .$ext): $filename"
        ((skipped++))
    fi
}
 
usage() {
    cat << EOF
Usage: $SCRIPT_NAME [DIRECTORY]
 
Organize files into category folders by file extension.
 
Categories:
  Images      → jpg, png, gif, svg, bmp, webp, heic, etc.
  Documents   → pdf, doc, docx, txt, md, xls, xlsx, ppt, etc.
  Video       → mp4, mkv, avi, mov, wmv, webm, etc.
  Audio       → mp3, wav, flac, aac, ogg, m4a, etc.
  Archives    → zip, tar, gz, rar, 7z, bz2, etc.
  Code        → py, js, ts, html, css, sh, rb, java, go, etc.
  Executables → exe, msi, dmg, app, deb, rpm, etc.
 
Examples:
  $SCRIPT_NAME              # Organize current directory
  $SCRIPT_NAME ~/Downloads  # Organize Downloads folder
EOF
}
 
# Parse arguments
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
    usage
    exit 0
fi
 
target="${1:-.}"
 
# Validate directory
if [[ ! -d "$target" ]]; then
    echo "Error: Directory not found: $target" >&2
    exit 1
fi
 
echo "Organizing files in: $target"
echo ""
 
# Process all files (non-recursively)
while IFS= read -r -d '' file; do
    organize_file "$file"
done < <(find "$target" -maxdepth 1 -type f -print0)
 
# Summary
echo ""
echo "=========================================="
echo "Summary:"
echo "  Moved:   $moved files"
echo "  Skipped: $skipped files"
[[ $errors -gt 0 ]] && echo "  Errors:  $errors"
echo "=========================================="
 
exit 0
 

Production features:

  1. Comprehensive categories: 60+ file extensions mapped to 7 categories
  2. Conflict handling: Won't overwrite existing files
  3. Error handling: Reports failures without stopping
  4. Clear feedback: Shows exactly what's happening to each file
  5. Safe: Only processes current directory (not recursive)

Extend it:

# Add custom categories
declare -A CATEGORIES=(
    # ... existing categories ...
 
    # My custom categories
    [blend]="3D_Models" [obj]="3D_Models" [fbx]="3D_Models"
    [sketch]="Design" [xd]="Design" [fig]="Design"
    [book]="Ebooks" [epub]="Ebooks" [mobi]="Ebooks"
)
 
Was this page helpful?
SR

Written by the ShellRAG Team

The ShellRAG editorial team writes practical, beginner-friendly Bash Shell tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.

Learn more about us →