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:
- 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)
- Safe transformations:
- Uses
tr -cdto remove characters (keeps only safe ones) sed 's/__*/_/g'collapses multiple underscores- Preserves file extensions correctly
- Uses
- 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:
- Flexible deployment:
- Interactive mode: See colorful output
- Cron mode:
-s(silent unless problems), logs to file, sends email - Customizable thresholds via environment variables
- 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)
- Robust detection:
- Tries multiple methods to check service status (pgrep, systemctl)
- Handles systems without GNU coreutils
- Gracefully skips unavailable checks
- 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:
- Comprehensive categories: 60+ file extensions mapped to 7 categories
- Conflict handling: Won't overwrite existing files
- Error handling: Reports failures without stopping
- Clear feedback: Shows exactly what's happening to each file
- 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"
)
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 →