Build a System Health Monitor
Write a Bash script that monitors disk, memory, and CPU with color alerts.
Introduction
30 Second Summary
Every computer has vital signs. Disk space, memory, and CPU load all tell you whether your system is healthy or about to crash.
In this project, you will build a command-line system health monitor script in Bash that checks disk usage, memory consumption, and CPU load, then prints a color-coded report with threshold-based warnings.
What You'll Build
A production-quality Bash script called health_check.sh that monitors your system's vital metrics and alerts you when they exceed configurable thresholds.
By the end of this project, you'll have:
- A working system health monitor script that checks disk, memory, and CPU metrics with color-coded output.
- Hands-on experience with production shell scripting patterns including strict mode, functions, and argument parsing.
- A ShellCheck-linted script with zero warnings, ready for your GitHub portfolio.
- Secret Mission: Schedule automated monitoring with cron and implement log rotation.
Are there any prerequisites?
You need WSL with Ubuntu already installed on your Windows machine. You should also be familiar with basic terminal commands like cd, ls, mkdir, and grep.
Before We Start
Every DevOps engineer needs a reliable way to check if their servers are healthy. Disk filling up, memory running low, CPU spiking. These are the exact problems that catch teams off guard at 2am.
In this step, you will set up your scripting environment and confirm that all the tools you need are ready. By the end, you will have a project folder and an executable script file waiting for your code.
In this step, get ready to:
- Verify your Bash version in WSL.
- Install ShellCheck for script linting.
- Create the project directory and an executable script file.
Verify your Bash environment and version
- Press the Windows key to open your search bar.
- Type Ubuntu and press Enter to open your WSL Ubuntu terminal.
What is WSL?
WSL (Windows Subsystem for Linux) lets you run a full Linux environment directly on Windows. When you open Ubuntu from the Start menu, you get a Linux terminal where you can run Bash commands just like on a real Linux server.
- Confirm your Bash version by running this command:
bash --version
✔️ I see version 5.x
Your terminal should show something like GNU bash, version 5.2.21. Any version starting with 5 means you are ready to continue.
ⓧ I see something else or get an error
If you see a version lower than 5 or get an error, your Ubuntu installation may be outdated.
- Try running the following to update your system:
sudo apt update && sudo apt upgrade -y
After the upgrade completes, run bash --version again to confirm you now see version 5.x.
- Confirm your Ubuntu version by running this command:
cat /etc/os-release
What should I see?
You should see output that includes NAME="Ubuntu" and a VERSION_ID field showing your Ubuntu release (e.g. 24.04). This confirms your Linux environment is working correctly.
Install ShellCheck and create your project structure
ShellCheck is a static analysis tool for shell scripts. It catches common bugs, syntax issues, and bad practices before your script ever runs.
- Install ShellCheck by running this command:
sudo apt update && sudo apt install shellcheck -y
What does this command do?
- sudo apt update refreshes the list of available packages so your system knows what is available to install.
- sudo apt install shellcheck -y installs ShellCheck without asking for confirmation (the -y flag auto-accepts).
Why is the system asking for a password?
The sudo command runs actions with administrator privileges. Your terminal will prompt you for the password you set when you first configured Ubuntu in WSL. Type it in and press Enter.
The characters will not appear as you type. This is normal.
- Verify ShellCheck installed successfully by running:
shellcheck --version
✔️ I see version information
You should see output including version: 0.9.0 (or similar). ShellCheck is ready to use.
ⓧ Command not found
If you see a "command not found" error, the installation may not have completed.
- Try running the install command again:
sudo apt install shellcheck -y
If it still fails, check your internet connection and try sudo apt update first.
- Create your project directory and navigate into it by running these commands:
mkdir -p ~/scripts/health-monitor && cd ~/scripts/health-monitor
What does this command do?
- mkdir -p creates the directory and any parent directories that do not exist yet (the scripts folder and the health-monitor folder inside it).
- cd ~/scripts/health-monitor moves your terminal into the new project directory so all future commands run from there.
- Confirm you are in the right directory by running:
pwd
You should see /home/your-username/scripts/health-monitor in the output.
Seeing a different path?
If pwd shows your home directory (~) instead of the project folder, the && may not have carried over from the previous command. Run cd ~/scripts/health-monitor separately.
Help me fix my directory issue.
Create your script file and make it executable
Every Bash script needs two things before it can run: the file itself, and the executable permission bit set on it.
- Create the script file and set its executable permission by running:
touch health_check.sh && chmod +x health_check.sh
What does this command do?
- touch health_check.sh creates an empty file called health_check.sh.
- chmod +x health_check.sh adds the executable permission so you can run the script directly with ./health_check.sh later.
- Confirm the file exists and has the right permissions by running:
ls -la health_check.sh
You should see something like -rwxrwxr-x at the start of the output. The x characters confirm the executable permission is set.
- Open the file in the nano text editor by running:
nano health_check.sh
What is nano?
Nano is a simple terminal-based text editor that comes pre-installed on Ubuntu. It opens directly in your terminal window. You will use it to write your health check script in the next step.
You should see an empty editor screen with a menu bar at the bottom showing shortcuts like ^X Exit and ^O Write Out. Leave nano open for now.
Terminal looks frozen or stuck?
If your terminal seems unresponsive after running the nano command, you are likely inside the nano editor already. Look for the menu bar at the bottom of the screen. If you accidentally closed it, run nano health_check.sh again.
Help me figure out what is happening in my terminal.
Your environment is ready. Next up, you will start writing the script with strict mode, color variables, and your first monitoring function.
Build the Script Skeleton and Disk Check
Your project directory is set up and ShellCheck is ready to lint your code. Now it's time to write actual Bash.
Every production shell script starts with a safety net and a clear structure. In this step, you'll build the script skeleton that all your monitoring functions will plug into, then write your first real health check: disk usage.
In this step, get ready to:
- Set up strict mode, color codes, and a logging helper function.
- Write a disk usage check function with threshold-based color alerts.
- Lint and run your script to confirm everything works.
Set up the script structure with strict mode and helper functions
Before writing any monitoring logic, you need a solid foundation. Production scripts use strict mode to catch bugs early, color codes for readable output, and a structured layout that keeps logic organized.
- Open health_check.sh in nano by running:
nano health_check.sh
- Add the script preamble with strict mode, color codes, and default thresholds by typing the following into the empty file:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Color codes
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
# Default thresholds
DISK_WARN="${DISK_WARN:-80}"
MEM_WARN="${MEM_WARN:-75}"
CPU_WARN="${CPU_WARN:-$(nproc)}"
What does this code do?
- #!/usr/bin/env bash is the shebang line. It tells your system to run this file with Bash regardless of where Bash is installed.
- set -euo pipefail activates strict mode. -e exits on any error, -u catches undefined variables, and -o pipefail makes pipe failures visible.
- IFS=$'\n\t' changes the field separator so your script won't accidentally split strings on spaces.
- The color variables store ANSI escape codes that your terminal interprets as colors. NC resets the color back to normal.
- The threshold variables use the ${VAR:-default} pattern. This means "use the environment variable if it exists, otherwise fall back to the default value."
You won't see a change yet. The next section adds the functions that make the script do something.
- Below the thresholds block, add the log() helper function, an empty main() function, and the main call at the bottom:
log() {
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "[$timestamp] $1"
}
main() {
:
}
main
What does this code do?
- log() is a helper that prepends a timestamp to any message you pass it. The local keyword keeps the variable scoped to this function.
- echo -e enables escape-sequence interpretation so your color codes render correctly.
- main() is currently a placeholder. The : is a Bash no-op command that does nothing but exit successfully.
- Calling main at the very bottom is a common Bash pattern. It lets you define all your functions above and run the entry point last.
- Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
- Run the script to confirm it exits cleanly:
./health_check.sh
You should see no output at all. The script runs the no-op and exits with code 0, which means success.
Seeing an error about syntax or permission?
- If you see Permission denied, the executable bit may have been lost. Run chmod +x health_check.sh and try again.
- If you see a syntax error, double-check that IFS=$'\n\t' uses a dollar sign before the single quote. Without it, Bash treats \n as literal characters.
- help me debug my health_check.sh script
Write the disk usage check function
Disk space running out is one of the most common causes of production outages. Your first monitoring function will read disk usage and report it with a color-coded severity level.
- Open the script again in nano:
nano health_check.sh
- Find the main() function near the bottom of the file. Add the check_disk() function directly above main(), on the line after the closing } of log():
check_disk() {
local usage_pct mount
usage_pct=$(df -P / | awk 'NR==2 {print $5}' | tr -d '%')
mount="/"
if [[ "$usage_pct" -ge 90 ]]; then
log "${RED}[CRITICAL] Disk ${mount}: ${usage_pct}% used${NC}"
elif [[ "$usage_pct" -ge "$DISK_WARN" ]]; then
log "${YELLOW}[WARNING] Disk ${mount}: ${usage_pct}% used${NC}"
else
log "${GREEN}[OK] Disk ${mount}: ${usage_pct}% used${NC}"
fi
}
What does this function do?
- df -P / checks disk usage on the root filesystem. The -P flag forces POSIX output so columns stay consistent regardless of device name length.
- awk 'NR==2 {print $5}' grabs the usage percentage from the second row, fifth column (skipping the header).
- tr -d '%' strips the percent sign so you can do integer math on the value.
- The if/elif/else block compares against two thresholds: 90% is always critical, and anything above DISK_WARN (default 80%) gets a warning. Everything else is OK.
- Each branch calls log() with a different color code so the status pops out visually in your terminal.
- Now update main() to call your new function. Find this code:
main() {
:
}
- Replace the : with check_disk so it looks like this:
main() {
check_disk
}
- Save the file with Ctrl+O, Enter, then exit with Ctrl+X.
Getting an error about check_disk not found?
- Make sure check_disk() is defined above main() in the file. Bash reads top to bottom, so a function must be declared before it's called.
- Check that the function name matches exactly. check_disk (with an underscore) must be spelled the same in both the function definition and the main() call.
- help me fix a function-not-found error in my Bash script
Test your script and lint with ShellCheck
Time to run the script for real and confirm ShellCheck is happy with your code.
- Run the health check script to see your disk status:
./health_check.sh
You should see a single line with a timestamp, a color-coded status tag, and your root disk usage percentage.
What should I see?
The output looks something like this (your percentage and color will vary):
[2026-06-13 14:30:01] [OK] Disk /: 12% used
If your disk is above 80% used, you'll see a yellow WARNING instead. Above 90% shows a red CRITICAL.
- Run ShellCheck to lint your script:
shellcheck health_check.sh
You should see no output at all. ShellCheck only prints messages when it finds warnings or errors. Silence means your code is clean.
ShellCheck showing warnings?
- ShellCheck warnings include a code like SC2034. Read the message carefully. It tells you the exact line number and what to fix.
- A common warning is about unused variables (the color codes and thresholds you defined). If you see this, make sure check_disk() actually references DISK_WARN, RED, YELLOW, GREEN, and NC in its body.
- help me fix ShellCheck warnings in my health_check.sh script
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, then move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete health_check.sh at this point:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Color codes
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
# Default thresholds
DISK_WARN="${DISK_WARN:-80}"
MEM_WARN="${MEM_WARN:-75}"
CPU_WARN="${CPU_WARN:-$(nproc)}"
log() {
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "[$timestamp] $1"
}
check_disk() {
local usage_pct mount
usage_pct=$(df -P / | awk 'NR==2 {print $5}' | tr -d '%')
mount="/"
if [[ "$usage_pct" -ge 90 ]]; then
log "${RED}[CRITICAL] Disk ${mount}: ${usage_pct}% used${NC}"
elif [[ "$usage_pct" -ge "$DISK_WARN" ]]; then
log "${YELLOW}[WARNING] Disk ${mount}: ${usage_pct}% used${NC}"
else
log "${GREEN}[OK] Disk ${mount}: ${usage_pct}% used${NC}"
fi
}
main() {
check_disk
}
main
Your script skeleton is solid, and you've already got a working disk monitor. Next up, you'll add memory and CPU monitoring to build a complete system health report.
Add Memory and CPU Monitoring
Your script already reports disk usage with color-coded alerts. Now it's time to monitor the other two critical metrics: memory and CPU load.
Linux exposes real-time system metrics through virtual files in /proc. You'll read from these files to calculate memory consumption and CPU load per core.
In this step, get ready to:
- Check memory usage by reading from /proc/meminfo.
- Check CPU load per core by reading from /proc/loadavg.
- Wire all three checks into a formatted health report with a header and footer.
Write the memory check function
Memory monitoring needs two values: how much total memory the system has, and how much is still available. The /proc/meminfo file provides both in kilobytes.
- In health_check.sh, find the closing } of your check_disk() function.
- Add the following check_memory() function below it:
check_memory() {
local mem_total mem_available used_mb total_mb usage_pct
mem_total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
mem_available=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
total_mb=$(( mem_total / 1024 ))
used_mb=$(( (mem_total - mem_available) / 1024 ))
if [[ "$total_mb" -gt 0 ]]; then
usage_pct=$(( 100 * used_mb / total_mb ))
else
usage_pct=0
fi
if [[ "$usage_pct" -ge 90 ]]; then
log "${RED}[CRITICAL] Memory: ${usage_pct}% used (${used_mb}MB/${total_mb}MB)${NC}"
elif [[ "$usage_pct" -ge "$MEM_WARN" ]]; then
log "${YELLOW}[WARNING] Memory: ${usage_pct}% used (${used_mb}MB/${total_mb}MB)${NC}"
else
log "${GREEN}[OK] Memory: ${usage_pct}% used (${used_mb}MB/${total_mb}MB)${NC}"
fi
}
What does this code do?
awk extracts the MemTotal and MemAvailable values from /proc/meminfo. These are in kilobytes, so the function divides by 1024 to get megabytes.
Shell arithmetic $(( )) calculates the usage percentage. The function then uses the same threshold pattern as check_disk() to print a color-coded status line.
- Save health_check.sh in nano by pressing Ctrl+O, then Enter.
You won't see a change yet. The next substep wires this function into main().
Seeing a syntax error when you save?
Check that every $(( )) arithmetic block has matching double parentheses. A missing parenthesis is the most common typo here.
Also confirm there's no extra space between awk and its single-quoted pattern. The pattern must start immediately after the space following awk.
Help me debug a syntax error in my check_memory function
Write the CPU load check function
CPU load represents how many processes are competing for processor time. The 1-minute load average from /proc/loadavg divided by the number of CPU cores gives a normalized per-core metric.
Why use awk instead of bc for float math?
Bash only supports integer arithmetic natively. You need an external tool for decimal division. awk is installed on every Linux system, while bc may be missing on minimal containers or stripped VMs.
The awk 'BEGIN {exit !(condition)}' pattern is a common production idiom for float comparisons in shell scripts.
- Below your check_memory() function, add the check_cpu() function:
check_cpu() {
local cores load1 load_per_core
cores=$(nproc)
load1=$(awk '{print $1}' /proc/loadavg)
load_per_core=$(awk -v l="$load1" -v c="$cores" 'BEGIN {printf "%.2f", l/c}')
if awk -v v="$load_per_core" -v t="$CPU_WARN" 'BEGIN {exit !(v >= t)}'; then
log "${RED}[CRITICAL] CPU load per core: ${load_per_core} (${cores} cores, load: ${load1})${NC}"
elif awk -v v="$load_per_core" -v t="$(awk -v w="$CPU_WARN" 'BEGIN {printf "%.2f", w * 0.7}')" 'BEGIN {exit !(v >= t)}'; then
log "${YELLOW}[WARNING] CPU load per core: ${load_per_core} (${cores} cores, load: ${load1})${NC}"
else
log "${GREEN}[OK] CPU load per core: ${load_per_core} (${cores} cores, load: ${load1})${NC}"
fi
}
What does this code do?
nproc returns the number of CPU cores available. awk '{print $1}' /proc/loadavg grabs the first field, which is the 1-minute load average.
The function divides load by cores to get a per-core value. A per-core load of 1.0 means the CPU is fully saturated. The threshold comparison uses awk's exit code to handle decimal numbers that Bash can't compare natively.
- Save health_check.sh by pressing Ctrl+O, then Enter.
You won't see a change yet. The next substep connects both new functions to main().
Confused by the nested awk calls?
The elif line uses a command substitution $(...) inside the -v t= argument. This calculates 70% of the threshold for the warning level. Make sure you have matching parentheses and quotes.
Help me understand the nested awk comparison in check_cpu
Wire everything together in main and test
Both check functions are ready. Now you need main() to call them and print a formatted report header so the output is easy to read at a glance.
- Find your main() function at the bottom of health_check.sh.
- Find this code:
main() {
check_disk
}
main
- Replace it with the following updated version that prints a header, runs all three checks, and prints a footer:
main() {
echo "========================================"
echo " System Health Report: $(hostname)"
echo " Generated: $(date)"
echo "========================================"
echo ""
check_disk
check_memory
check_cpu
echo ""
echo "========================================"
}
main
What does this code do?
hostname and date are command substitutions that print your machine's name and the current timestamp. This gives each report a clear identity.
The three check functions run in sequence. Each prints its own status line, building a complete health report between the header and footer separators.
- Save health_check.sh by pressing Ctrl+O, then Enter.
- Exit nano by pressing Ctrl+X.
- Run your script by executing:
./health_check.sh
You should see a report with a header line showing your hostname and date, followed by three color-coded status lines for disk, memory, and CPU, then a footer separator.
- Confirm your script passes linting by running:
shellcheck health_check.sh
You should see no output at all. That means zero warnings.
Seeing errors when you run the script?
If you see command not found: check_memory, make sure your new functions are defined ABOVE the main() function. Bash reads top to bottom, so functions must exist before they're called.
If shellcheck reports a warning about double-quoting, wrap any unquoted variable references in double quotes. The strict mode set -u will also catch undefined variables at runtime.
Help me fix errors in my health check script
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and that shellcheck health_check.sh produces no output.
ⓧ I'd like to double check the full code
Here's the complete health_check.sh as it should look at this point:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Color codes
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
# Default thresholds
DISK_WARN="${DISK_WARN:-80}"
MEM_WARN="${MEM_WARN:-75}"
CPU_WARN="${CPU_WARN:-$(nproc)}"
log() {
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "[$timestamp] $1"
}
check_disk() {
local usage_pct mount
usage_pct=$(df -P / | awk 'NR==2 {print $5}' | tr -d '%')
mount="/"
if [[ "$usage_pct" -ge 90 ]]; then
log "${RED}[CRITICAL] Disk ${mount}: ${usage_pct}% used${NC}"
elif [[ "$usage_pct" -ge "$DISK_WARN" ]]; then
log "${YELLOW}[WARNING] Disk ${mount}: ${usage_pct}% used${NC}"
else
log "${GREEN}[OK] Disk ${mount}: ${usage_pct}% used${NC}"
fi
}
check_memory() {
local mem_total mem_available used_mb total_mb usage_pct
mem_total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
mem_available=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
total_mb=$(( mem_total / 1024 ))
used_mb=$(( (mem_total - mem_available) / 1024 ))
if [[ "$total_mb" -gt 0 ]]; then
usage_pct=$(( 100 * used_mb / total_mb ))
else
usage_pct=0
fi
if [[ "$usage_pct" -ge 90 ]]; then
log "${RED}[CRITICAL] Memory: ${usage_pct}% used (${used_mb}MB/${total_mb}MB)${NC}"
elif [[ "$usage_pct" -ge "$MEM_WARN" ]]; then
log "${YELLOW}[WARNING] Memory: ${usage_pct}% used (${used_mb}MB/${total_mb}MB)${NC}"
else
log "${GREEN}[OK] Memory: ${usage_pct}% used (${used_mb}MB/${total_mb}MB)${NC}"
fi
}
check_cpu() {
local cores load1 load_per_core
cores=$(nproc)
load1=$(awk '{print $1}' /proc/loadavg)
load_per_core=$(awk -v l="$load1" -v c="$cores" 'BEGIN {printf "%.2f", l/c}')
if awk -v v="$load_per_core" -v t="$CPU_WARN" 'BEGIN {exit !(v >= t)}'; then
log "${RED}[CRITICAL] CPU load per core: ${load_per_core} (${cores} cores, load: ${load1})${NC}"
elif awk -v v="$load_per_core" -v t="$(awk -v w="$CPU_WARN" 'BEGIN {printf "%.2f", w * 0.7}')" 'BEGIN {exit !(v >= t)}'; then
log "${YELLOW}[WARNING] CPU load per core: ${load_per_core} (${cores} cores, load: ${load1})${NC}"
else
log "${GREEN}[OK] CPU load per core: ${load_per_core} (${cores} cores, load: ${load1})${NC}"
fi
}
main() {
echo "========================================"
echo " System Health Report: $(hostname)"
echo " Generated: $(date)"
echo "========================================"
echo ""
check_disk
check_memory
check_cpu
echo ""
echo "========================================"
}
main
Your health monitor now covers all three critical system metrics. Next up, you'll add argument parsing so users can customize the warning thresholds from the command line.
Add Argument Parsing and Help
Your script now monitors disk, memory, and CPU with color-coded alerts. But the threshold values are locked inside the source code. If a teammate wants different thresholds, they'd have to open your script and edit the variables by hand.
Professional scripts accept command-line flags so users can customize behavior without touching the code. In this step, you'll add argument parsing so anyone can run your script with --disk-warn 50 or --help to see available options.
In this step, get ready to:
- Add a usage function that prints help text.
- Build a while/case loop to parse command-line flags.
- Test the argument parsing with different inputs.
Add a usage function
Your threshold variables (DISK_WARN, MEM_WARN, CPU_WARN) already have sensible defaults using the ${VAR:-default} pattern. Now you need a way to tell users about these options. A usage() function prints a help message showing all available flags and their current defaults.
- Open health_check.sh in nano by running:
nano health_check.sh
- Find the log() function. Add the following usage() function directly below it (after the closing } of log, before check_disk):
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
System Health Monitor - checks disk, memory, and CPU load.
Options:
--disk-warn <pct> Disk usage warning threshold (default: ${DISK_WARN}%)
--mem-warn <pct> Memory usage warning threshold (default: ${MEM_WARN}%)
--cpu-warn <load> CPU load-per-core warning threshold (default: ${CPU_WARN})
--log Write output to a timestamped log file
-h, --help Show this help message and exit
EOF
}
What does this code do?
The cat <<EOF syntax is a heredoc. It lets you print multiple lines of text without repeating echo on every line.
The $(basename "$0") part dynamically inserts the script's own filename. The ${DISK_WARN} references show users the current default values, so the help text always stays accurate.
- While you have the file open, also add these section comment headers to organize your script. Add this line between IFS=$'\n\t' and the # Color codes comment:
# -----------------------------------------------
# System Health Monitor
# -----------------------------------------------
- Add this header above the log() function:
# -----------------------------------------------
# Helper functions
# -----------------------------------------------
- Add this header above the check_disk() function:
# -----------------------------------------------
# Check functions
# -----------------------------------------------
Why add section headers?
As scripts grow, section headers make it easy to jump to the right part of the file. This is a common convention in production shell scripts that helps teammates navigate your code.
- Save the file with Ctrl+O, press Enter, then exit with Ctrl+X.
- Confirm the usage function works by running:
bash -c 'source health_check.sh && usage'
You should see the help text printed with all the flag descriptions and default values.
Seeing a syntax error?
Make sure the EOF closing tag is on its own line with no leading spaces. Heredoc terminators are whitespace-sensitive.
Also check that the opening cat <<EOF has no space between << and EOF.
Help me debug my usage function heredoc.
Implement argument parsing with a while loop and case statement
Bash scripts receive command-line arguments as positional parameters ($1, $2, etc.). A while/case loop processes each argument one at a time, using shift to advance through the list.
- Open health_check.sh in nano again:
nano health_check.sh
- Find the main() function. Add the following argument parsing block above the main() function definition:
# -----------------------------------------------
# Argument parsing
# -----------------------------------------------
LOG_TO_FILE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--disk-warn)
DISK_WARN="$2"
shift 2
;;
--mem-warn)
MEM_WARN="$2"
shift 2
;;
--cpu-warn)
CPU_WARN="$2"
shift 2
;;
--log)
LOG_TO_FILE=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: Unknown option '$1'" >&2
usage >&2
exit 1
;;
esac
done
What does this code do?
The while [[ $# -gt 0 ]] loop runs as long as there are unprocessed arguments. Each case branch matches a specific flag and handles it.
The shift 2 command discards both the flag and its value (e.g. --disk-warn and 50), advancing to the next argument. Flags without values (like --log) only need shift (which shifts by 1).
The * wildcard catches anything that doesn't match a known flag. It prints an error to >&2 (stderr) and exits with code 1 to signal failure.
- Now scroll to the very bottom of the file. Find the bare main call (the last line):
main
- Delete that line and replace it with the following conditional block. Also add the # Main section header above the main() function:
# -----------------------------------------------
# Main
# -----------------------------------------------
main() {
echo "========================================"
echo " System Health Report: $(hostname)"
echo " Generated: $(date)"
echo "========================================"
echo ""
check_disk
check_memory
check_cpu
echo ""
echo "========================================"
}
if [[ "$LOG_TO_FILE" == true ]]; then
LOG_DIR="$(dirname "$0")/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_DIR}/health_$(date '+%Y%m%d_%H%M%S').log"
main | tee "$LOG_FILE"
# Clean up logs older than 7 days
find "$LOG_DIR" -name "health_*.log" -mtime +7 -delete
else
main
fi
What does this code do?
Instead of always calling main directly, the script now checks the LOG_TO_FILE variable. When --log is passed, it creates a logs/ directory, pipes output through tee (which prints to screen AND saves to a file), and cleans up old logs.
The find command with -mtime +7 -delete removes any log files older than 7 days. This is a simple log rotation pattern used in production.
- Save with Ctrl+O, press Enter, then exit with Ctrl+X.
Seeing unexpected token errors?
Check that every ;; is on its own line inside the case block. Also verify that esac and done each appear on their own line after the case block closes.
Help me fix syntax errors in my argument parsing loop.
Test argument parsing
Time to verify all three behaviors: the help flag, a custom threshold, and an unknown flag.
- Test the help flag by running:
./health_check.sh --help
You should see the full usage message with all available options and their default values.
- Test a custom threshold by running:
./health_check.sh --disk-warn 50
Your disk check line should now trigger a warning (yellow or red) since most systems use more than 50% disk.
- Test unknown flag handling by running:
./health_check.sh --invalid-flag
You should see an error message followed by the usage text, printed to stderr.
- Run a final shellcheck pass to confirm clean output:
shellcheck health_check.sh
You should see no warnings or errors. Your script passes ShellCheck linting with zero issues.
ShellCheck showing warnings?
If you see a warning about $# or quoting, make sure the while condition uses double brackets: [[ $# -gt 0 ]].
If you see warnings about variables in the heredoc, that's expected behavior. ShellCheck sometimes flags variable expansion inside heredocs. Check that the warning count is zero in your actual output.
Help me fix ShellCheck warnings in my argument parsing script.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# -----------------------------------------------
# System Health Monitor
# -----------------------------------------------
# Color codes
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
# Default thresholds
DISK_WARN="${DISK_WARN:-80}"
MEM_WARN="${MEM_WARN:-75}"
CPU_WARN="${CPU_WARN:-$(nproc)}"
# -----------------------------------------------
# Helper functions
# -----------------------------------------------
log() {
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "[$timestamp] $1"
}
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
System Health Monitor - checks disk, memory, and CPU load.
Options:
--disk-warn <pct> Disk usage warning threshold (default: ${DISK_WARN}%)
--mem-warn <pct> Memory usage warning threshold (default: ${MEM_WARN}%)
--cpu-warn <load> CPU load-per-core warning threshold (default: ${CPU_WARN})
--log Write output to a timestamped log file
-h, --help Show this help message and exit
EOF
}
# -----------------------------------------------
# Check functions
# -----------------------------------------------
check_disk() {
local usage_pct mount
usage_pct=$(df -P / | awk 'NR==2 {print $5}' | tr -d '%')
mount="/"
if [[ "$usage_pct" -ge 90 ]]; then
log "${RED}[CRITICAL] Disk ${mount}: ${usage_pct}% used${NC}"
elif [[ "$usage_pct" -ge "$DISK_WARN" ]]; then
log "${YELLOW}[WARNING] Disk ${mount}: ${usage_pct}% used${NC}"
else
log "${GREEN}[OK] Disk ${mount}: ${usage_pct}% used${NC}"
fi
}
check_memory() {
local mem_total mem_available used_mb total_mb usage_pct
mem_total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
mem_available=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
total_mb=$(( mem_total / 1024 ))
used_mb=$(( (mem_total - mem_available) / 1024 ))
if [[ "$total_mb" -gt 0 ]]; then
usage_pct=$(( 100 * used_mb / total_mb ))
else
usage_pct=0
fi
if [[ "$usage_pct" -ge 90 ]]; then
log "${RED}[CRITICAL] Memory: ${usage_pct}% used (${used_mb}MB/${total_mb}MB)${NC}"
elif [[ "$usage_pct" -ge "$MEM_WARN" ]]; then
log "${YELLOW}[WARNING] Memory: ${usage_pct}% used (${used_mb}MB/${total_mb}MB)${NC}"
else
log "${GREEN}[OK] Memory: ${usage_pct}% used (${used_mb}MB/${total_mb}MB)${NC}"
fi
}
check_cpu() {
local cores load1 load_per_core
cores=$(nproc)
load1=$(awk '{print $1}' /proc/loadavg)
load_per_core=$(awk -v l="$load1" -v c="$cores" 'BEGIN {printf "%.2f", l/c}')
if awk -v v="$load_per_core" -v t="$CPU_WARN" 'BEGIN {exit !(v >= t)}'; then
log "${RED}[CRITICAL] CPU load per core: ${load_per_core} (${cores} cores, load: ${load1})${NC}"
elif awk -v v="$load_per_core" -v t="$(awk -v w="$CPU_WARN" 'BEGIN {printf "%.2f", w * 0.7}')" 'BEGIN {exit !(v >= t)}'; then
log "${YELLOW}[WARNING] CPU load per core: ${load_per_core} (${cores} cores, load: ${load1})${NC}"
else
log "${GREEN}[OK] CPU load per core: ${load_per_core} (${cores} cores, load: ${load1})${NC}"
fi
}
# -----------------------------------------------
# Argument parsing
# -----------------------------------------------
LOG_TO_FILE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--disk-warn)
DISK_WARN="$2"
shift 2
;;
--mem-warn)
MEM_WARN="$2"
shift 2
;;
--cpu-warn)
CPU_WARN="$2"
shift 2
;;
--log)
LOG_TO_FILE=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: Unknown option '$1'" >&2
usage >&2
exit 1
;;
esac
done
# -----------------------------------------------
# Main
# -----------------------------------------------
main() {
echo "========================================"
echo " System Health Report: $(hostname)"
echo " Generated: $(date)"
echo "========================================"
echo ""
check_disk
check_memory
check_cpu
echo ""
echo "========================================"
}
if [[ "$LOG_TO_FILE" == true ]]; then
LOG_DIR="$(dirname "$0")/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_DIR}/health_$(date '+%Y%m%d_%H%M%S').log"
main | tee "$LOG_FILE"
# Clean up logs older than 7 days
find "$LOG_DIR" -name "health_*.log" -mtime +7 -delete
else
main
fi
Your health monitor now accepts command-line flags like a professional CLI tool. Next up, you'll take on the Secret Mission and schedule this script to run automatically with cron.
Secret mission
Schedule with Cron
Your health monitor runs on demand, but real DevOps engineers schedule monitoring to run automatically. In this secret mission, you'll deploy your script with cron to run every 5 minutes and log results to a file. You'll also verify the log rotation you already built is working in production.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your WSL Ubuntu system, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- ~/scripts/health-monitor/ directory (your script, logs, and project files).
- ShellCheck (installed via apt).
- Crontab entry running your health check every 5 minutes (Secret Mission).
Keep everything running
No action needed. Choose this if you are still actively building or want your cron job to keep generating health reports.
- Your script stays at ~/scripts/health-monitor/health_check.sh and remains executable.
- Your cron job continues running every 5 minutes, writing logs to ~/scripts/health-monitor/logs/.
- The built-in log rotation deletes logs older than 7 days automatically.
- ShellCheck remains installed for future scripts.
Pause - I'll come back to this later
Stop the automated cron job so it is not writing logs while you are away. Your script and files stay on disk for when you return.
- Remove the cron entry by running:
crontab -l | grep -v 'health_check.sh' | crontab -
- Verify it is gone by running:
crontab -l
- Your ~/scripts/health-monitor/ folder and ShellCheck remain untouched for next time.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your script, logs, the cron job, and the ShellCheck package.
- Remove the cron entry by running:
crontab -l | grep -v 'health_check.sh' | crontab -
- Delete the entire project directory by running:
rm -rf ~/scripts/health-monitor
- If the ~/scripts/ directory is now empty and you do not plan to use it, remove it too:
rmdir ~/scripts 2>/dev/null || true
- Uninstall ShellCheck by running:
sudo apt remove shellcheck -y
- Verify the cron entry is gone:
crontab -l
- Confirm the project directory no longer exists:
ls ~/scripts/health-monitor
You should see a "No such file or directory" message, confirming the folder is deleted.
Nice Work!
Nice Work!
You just built a production-quality system health monitoring script entirely from scratch using Bash. This is exactly the kind of automation script that DevOps and Cloud engineers write and maintain daily.
You've learned how to:
- Built a system health monitor that reads from /proc/meminfo, /proc/loadavg, and df to report disk, memory, and CPU status with color-coded threshold alerts.
- Applied production shell scripting patterns including strict mode (set -euo pipefail), reusable functions, and ShellCheck linting for zero-warning code.
- Implemented command-line argument parsing with a while/case pattern so thresholds are configurable via flags like --disk-warn and --help.
- Secret Mission: Scheduled automated health checks with cron and added log rotation to manage output files over time.
Ready to quiz yourself?