A monitoring system can only act on what a check tells it. In this project you write health-check.sh, a script that looks at disk use, free memory, the load average and a list of processes, prints a short table of OK, WARN and FAIL lines, and exits with 0, 1 or 2 so that cron, a monitor or another script knows how worried to be.
It uses Arguments and input for getopts and the usage message, Functions and Arrays for the structure, Arithmetic and strings for the sums and printf, Exit codes and errors for the status it returns, Robust scripts for set -euo pipefail and a trap, and Real-world scripts for the config file, logging and cron. From the Linux skill it draws on Disks and space, Watching a machine, Processes and Running things on a schedule.
What you need
The practice machine from Shell scripting basics, started from ~/scripts on your computer:
$ cd ~/scripts
$ docker run -it --rm --hostname lab -v "$PWD":/root/scripts -w /root/scripts ubuntu:24.04
root@lab:~/scripts#
Install cron and ShellCheck, and start the cron service by hand, because the container has no init system to do it for you:
root@lab:~/scripts# apt update && apt install -y cron shellcheck
root@lab:~/scripts# service cron start
* Starting periodic command scheduler cron
...done.
The container runs very little, so give the script something to look for. sleep infinity runs until it is killed; start one in the background as a stand-in for a service:
root@lab:~/scripts# sleep infinity &
[1] 412
Your process ID will differ. The memory and load figures in this page come from one machine; yours will show different numbers, which is fine.
The brief
./health-check.shwith no options checks disk use of/, available memory and the load average, and prints a table with a header and one line per check. Check: run it and read the table.-d MOUNTchecks a mount point and-p NAMEchecks that a process is running. Both can be given more than once. Check:./health-check.sh -d / -d /root/scripts -p sleepshows two disk lines and a process line.- Every threshold has a default, can be set in a config file read with
-c FILE, and can be overridden on the command line with-s KEY=VALUE. Check:-s DISK_WARN=1turns the disk line to WARN. - The script exits 0 when everything is OK, 1 when the worst result is WARN, and 2 when anything is FAIL. A usage error exits 3. Check:
echo $?after each kind of run. -o FILEalso writes the report, with the host name and time, to a file. The file is replaced in one step, so a reader never sees half a report. Check:catthe file.- The script passes ShellCheck and has a usage message. Check:
shellcheck health-check.shprints nothing, and-hprints the usage. - Cron runs it every five minutes and records an alert line whenever it exits non-zero. Check:
crontab -l, and a temporary every-minute entry writes the report within a minute.
Steps
Gather the numbers by hand
Before you write a script, find each number at the prompt. You need four things:
- Disk use of a mount point, as a percentage.
df --output=pcent /prints just that column; the last line holds the number, with spaces and a%around it. - Available memory.
/proc/meminfohasMemTotalandMemAvailablein kilobytes. UseMemAvailable, notMemFree: Linux uses spare memory for its file cache and gives it back when a program needs it, so free memory is always low on a busy machine. - Load. The first number in
/proc/loadavgis the 1-minute load average. A load of 2.0 means very different things on 1 CPU and on 16, so compare it withnproc. - Processes.
pgrep -x NAMEmatches the process name exactly, and-cprints a count.
Hint: Bash does whole-number maths only. Load is a decimal, so turn 0.39 into hundredths, 39, before you divide by the CPU count. Watch out for a part like 08: Bash reads a leading zero as octal, and 08 is not valid octal. 10# forces base 10.
Show a solution
root@lab:~/scripts# df --output=pcent / | tail -n 1
5%
root@lab:~/scripts# grep -E '^Mem(Total|Available):' /proc/meminfo
MemTotal: 8025128 kB
MemAvailable: 7280516 kB
root@lab:~/scripts# cat /proc/loadavg; nproc
0.36 0.24 0.16 1/307 23
8
root@lab:~/scripts# pgrep -cx sleep
1
root@lab:~/scripts# pgrep -cx cron
1
root@lab:~/scripts# pgrep -cx nginx; echo "exit $?"
0
exit 1
pgrep exits 1 when nothing matches. Under set -e that would stop the script, so the finished script writes pgrep -cx -- "$name" || true.
The load sum, with the 10# guard:
root@lab:~/scripts# load=0.36; cpus=8
root@lab:~/scripts# whole=${load%.*}; frac=${load#*.}
root@lab:~/scripts# echo $(( (10#$whole * 100 + 10#$frac) / cpus ))
4
That is 4 percent of the machine's capacity. 100 percent means every CPU is busy on average.
Plan the script
Decide the shape before you type. A plan that works well:
- Settings at the top as plain variables with defaults:
disk_warn=80,disk_fail=90,mem_warn=20andmem_fail=10(percent available),load_warn=100andload_fail=200(load as a percentage of the CPUs), an arraymounts=(/)and an empty arrayprocesses=(). - One function per check:
check_disk,check_memory,check_load,check_process. Each one callsrecord STATUS CHECK DETAILand does nothing else with the output. recordformats a line withprintfinto aresultsarray and remembers the worst status so far inworst, as 0, 1 or 2. At the end,exit "$worst"is the whole exit-code logic.- A usage message,
loganddie.logwrites a timestamped line to standard error, so the table on standard output stays clean for anyone piping it.dielogs and exits 3, which monitoring tools commonly read as "unknown": the check itself could not run.
Hint: keep the layout in one place. A format like '%-6s %-20s %s' pads the status to 6 characters and the check name to 20, so the columns line up whatever the values are.
Show a solution
This is how record and one check look. The full script is in the next stage.
record() {
local status=$1 check=$2 detail=$3 code
case $status in
OK) code=0 ;;
WARN) code=1 ;;
*) code=2 ;;
esac
(( code > worst )) && worst=$code
results+=("$(printf '%-6s %-20s %s' "$status" "$check" "$detail")")
}
Storing finished lines in an array means the report can be printed to the screen and written to a file without running the checks twice.
(( code > worst )) && worst=$code returns a non-zero status when the test is false, but set -e ignores failures on the left of &&, so it is safe here. It would not be safe as the last line of the function, because the function would then return that status.
Add options and a config file
The thresholds that suit a build server do not suit a database server, so they must be changeable without editing the script. Use two layers: a config file for each machine, and -s KEY=VALUE on the command line for one-off changes. The command line wins.
The config file is plain KEY=VALUE lines with # comments. You could source it, but then anything in the file runs as code, as root, from cron. Reading it line by line with IFS='=' read -r key value and accepting only known keys is safer, and it gives a clear error for a typo.
Hint: send both the file and -s through one function, set_setting KEY VALUE, so they cannot drift apart. Check numbers with [[ $value =~ ^[0-9]+$ ]]. For lists, read -ra mounts <<< "$value" splits a space-separated value into an array. Since -c may come after -s on the command line, collect the options in getopts first and apply them afterwards in the right order.
Show a solution
Save this as health.conf:
# Settings for health-check.sh
DISK_WARN=70
DISK_FAIL=90
MEM_WARN=25
MEM_FAIL=10
LOAD_WARN=100
LOAD_FAIL=200
MOUNTS=/ /root/scripts
PROCESSES=sleep cron
And this is the whole script, health-check.sh:
#!/bin/bash
# Checks disk use, available memory, load and named processes.
# Exits 0 when all is well, 1 on a warning, 2 on a failure and 3 on a usage error.
set -euo pipefail
readonly SCRIPT_NAME=${0##*/}
# Defaults. A config file (-c) changes them, and -s changes them again.
disk_warn=80 # percent used
disk_fail=90
mem_warn=20 # percent available
mem_fail=10
load_warn=100 # 1-minute load as a percentage of the CPU count
load_fail=200
mounts=(/)
processes=()
report_file=""
results=()
worst=0
usage() {
cat <<USAGE
Usage: $SCRIPT_NAME [-c FILE] [-s KEY=VALUE]... [-d MOUNT]... [-p NAME]... [-o FILE]
-c FILE read settings from FILE (KEY=VALUE lines)
-s KEY=VALUE set one setting, overriding the config file
-d MOUNT check disk use of MOUNT (repeatable, default /)
-p NAME check that a process called NAME is running (repeatable)
-o FILE also write the report to FILE
-h show this help
Keys: DISK_WARN DISK_FAIL MEM_WARN MEM_FAIL LOAD_WARN LOAD_FAIL MOUNTS PROCESSES
USAGE
}
log() {
printf '%s %s: %s\n' "$(date '+%F %T')" "$SCRIPT_NAME" "$*" >&2
}
die() {
log "ERROR: $*"
exit 3
}
is_number() {
[[ $1 =~ ^[0-9]+$ ]]
}
# Applies one KEY=VALUE setting from the config file or -s.
set_setting() {
local key=$1 value=$2
case $key in
DISK_WARN|DISK_FAIL|MEM_WARN|MEM_FAIL|LOAD_WARN|LOAD_FAIL)
is_number "$value" || die "$key must be a whole number, got '$value'"
printf -v "${key,,}" '%s' "$value"
;;
MOUNTS) read -ra mounts <<< "$value" ;;
PROCESSES) read -ra processes <<< "$value" ;;
*) die "unknown setting '$key'" ;;
esac
}
load_config() {
local file=$1 key value
[[ -r $file ]] || die "cannot read config file $file"
while IFS='=' read -r key value || [[ -n $key ]]; do
[[ -z $key || $key == \#* ]] && continue
set_setting "$key" "$value"
done < "$file"
}
# Records one result line and remembers the worst status seen so far.
record() {
local status=$1 check=$2 detail=$3 code
case $status in
OK) code=0 ;;
WARN) code=1 ;;
*) code=2 ;;
esac
(( code > worst )) && worst=$code
results+=("$(printf '%-6s %-20s %s' "$status" "$check" "$detail")")
}
# Prints OK, WARN or FAIL for a value where higher is worse.
grade_high() {
local value=$1 warn=$2 fail=$3
if (( value >= fail )); then echo FAIL
elif (( value >= warn )); then echo WARN
else echo OK
fi
}
check_disk() {
local mount=$1 used
if ! used=$(df --output=pcent "$mount" 2>/dev/null | tail -n 1); then
record FAIL "disk $mount" "not found"
return
fi
used=${used//[ %]/}
record "$(grade_high "$used" "$disk_warn" "$disk_fail")" "disk $mount" "${used}% used"
}
check_memory() {
local total available pct status
total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
available=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
pct=$(( available * 100 / total ))
if (( pct <= mem_fail )); then status=FAIL
elif (( pct <= mem_warn )); then status=WARN
else status=OK
fi
record "$status" "memory" "${pct}% available ($(( available / 1024 )) MiB)"
}
check_load() {
local load1 cpus whole frac pct
read -r load1 _ < /proc/loadavg
cpus=$(nproc)
whole=${load1%.*}
frac=${load1#*.}
# Work in hundredths so Bash's integer maths is enough: 1.50 becomes 150.
pct=$(( (10#$whole * 100 + 10#$frac) / cpus ))
record "$(grade_high "$pct" "$load_warn" "$load_fail")" "load" "$load1 on $cpus CPUs (${pct}%)"
}
check_process() {
local name=$1 count
count=$(pgrep -cx -- "$name" || true)
if (( count > 0 )); then
record OK "process $name" "$count running"
else
record FAIL "process $name" "not running"
fi
}
print_table() {
printf '%-6s %-20s %s\n' STATUS CHECK DETAIL
printf '%s\n' "${results[@]}"
}
write_report() {
local file=$1 tmp
tmp=$(mktemp "$file.XXXXXX") || die "cannot write to $(dirname "$file")"
trap 'rm -f "$tmp"' EXIT
{
printf 'Health check on %s at %s\n' "$(hostname)" "$(date '+%F %T')"
print_table
} > "$tmp"
mv "$tmp" "$file"
trap - EXIT
log "report written to $file"
}
main() {
local opt config="" extra_mounts=() extra_processes=() overrides=()
while getopts ':c:s:d:p:o:h' opt; do
case $opt in
c) config=$OPTARG ;;
s) overrides+=("$OPTARG") ;;
d) extra_mounts+=("$OPTARG") ;;
p) extra_processes+=("$OPTARG") ;;
o) report_file=$OPTARG ;;
h) usage; exit 0 ;;
:) usage >&2; die "option -$OPTARG needs a value" ;;
*) usage >&2; die "unknown option -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
(( $# == 0 )) || { usage >&2; die "unexpected argument '$1'"; }
# Config file first, then -s, so the command line wins.
[[ -n $config ]] && load_config "$config"
local pair
for pair in "${overrides[@]}"; do
[[ $pair == *=* ]] || die "-s needs KEY=VALUE, got '$pair'"
set_setting "${pair%%=*}" "${pair#*=}"
done
(( ${#extra_mounts[@]} > 0 )) && mounts=("${extra_mounts[@]}")
(( ${#extra_processes[@]} > 0 )) && processes=("${extra_processes[@]}")
local mount name
for mount in "${mounts[@]}"; do
check_disk "$mount"
done
check_memory
check_load
for name in "${processes[@]}"; do
check_process "$name"
done
print_table
[[ -n $report_file ]] && write_report "$report_file"
exit "$worst"
}
main "$@"
Two lines deserve a closer look:
printf -v "${key,,}" '%s' "$value"assigns to the variable whose name is the key in lower case, soDISK_WARNsetsdisk_warn. Thecaseabove it means only known names get this far.- In
write_report, the report goes to a temporary file in the same directory and is then renamed over the old one withmv. A rename within one file system is a single step, so a monitor reading the file sees the old report or the new one, never half of one. Thetrapremoves the temporary file if the script dies before the rename.
root@lab:~/scripts# chmod +x health-check.sh
root@lab:~/scripts# shellcheck health-check.sh
root@lab:~/scripts#
Run it from cron
A health check earns its keep when nobody is watching. Add a cron entry that runs it every five minutes with the config file, writes the report to /var/log/health-report.txt, and appends a line to /var/log/health-alerts.log whenever it exits non-zero. In a real setup that last part might send a message or call a monitoring system instead.
Cron runs jobs with a short PATH, no terminal and /bin/sh, not Bash. Use full paths for the script and the config file, and keep the command on the line simple.
Hint: in cmd || other, $? inside other is still the status of cmd, but only until something else runs. Put $? before any $(date) in the same line. Add a temporary every-minute entry to test it, then remove it.
Show a solution
root@lab:~/scripts# EDITOR=nano crontab -e
Install nano first with apt install -y nano if you have not. Add these lines, save and exit:
*/5 * * * * /root/scripts/health-check.sh -c /root/scripts/health.conf -o /var/log/health-report.txt > /dev/null 2>&1 || echo "exit $? at $(date)" >> /var/log/health-alerts.log
* * * * * /root/scripts/health-check.sh -c /root/scripts/health.conf -o /var/log/health-report.txt > /dev/null 2>&1 || echo "exit $? at $(date)" >> /var/log/health-alerts.log
Wait until the minute after next, then look at what cron produced:
root@lab:~/scripts# date
Sun Oct 25 09:20:15 UTC 2026
root@lab:~/scripts# cat /var/log/health-report.txt
Health check on lab at 2026-10-25 09:20:01
STATUS CHECK DETAIL
OK disk / 5% used
OK disk /root/scripts 61% used
OK memory 92% available (7269 MiB)
OK load 0.31 on 8 CPUs (3%)
OK process sleep 1 running
OK process cron 2 running
root@lab:~/scripts# cat /var/log/health-alerts.log
cat: /var/log/health-alerts.log: No such file or directory
Every check is OK, because cron and sleep are both running. There are two cron processes: the daemon, and the copy of it that is running this job. Stop the sleep and wait another minute:
root@lab:~/scripts# pkill -x sleep
[1]+ Terminated sleep infinity
root@lab:~/scripts# cat /var/log/health-report.txt
Health check on lab at 2026-10-25 09:21:01
STATUS CHECK DETAIL
OK disk / 5% used
OK disk /root/scripts 61% used
OK memory 92% available (7280 MiB)
OK load 0.63 on 8 CPUs (7%)
FAIL process sleep not running
OK process cron 2 running
root@lab:~/scripts# cat /var/log/health-alerts.log
exit 2 at Sun Oct 25 09:21:01 UTC 2026
The alert log shows exit 2, a failure. Now run crontab -e again and delete the every-minute line.
Check it works
The script is clean and has a usage message:
root@lab:~/scripts# shellcheck health-check.sh && echo "no warnings"
no warnings
root@lab:~/scripts# ./health-check.sh -h
Usage: health-check.sh [-c FILE] [-s KEY=VALUE]... [-d MOUNT]... [-p NAME]... [-o FILE]
-c FILE read settings from FILE (KEY=VALUE lines)
-s KEY=VALUE set one setting, overriding the config file
-d MOUNT check disk use of MOUNT (repeatable, default /)
-p NAME check that a process called NAME is running (repeatable)
-o FILE also write the report to FILE
-h show this help
Keys: DISK_WARN DISK_FAIL MEM_WARN MEM_FAIL LOAD_WARN LOAD_FAIL MOUNTS PROCESSES
All OK exits 0:
root@lab:~/scripts# ./health-check.sh -p sleep; echo "exit $?"
STATUS CHECK DETAIL
OK disk / 5% used
OK memory 92% available (7223 MiB)
OK load 0.17 on 8 CPUs (2%)
OK process sleep 1 running
exit 0
A warning exits 1, and the report file matches the screen:
root@lab:~/scripts# ./health-check.sh -c health.conf -s DISK_WARN=1 -p sleep -o /tmp/report.txt; echo "exit $?"
STATUS CHECK DETAIL
WARN disk / 5% used
WARN disk /root/scripts 61% used
OK memory 92% available (7221 MiB)
OK load 0.17 on 8 CPUs (2%)
OK process sleep 1 running
2026-10-25 09:17:31 health-check.sh: report written to /tmp/report.txt
exit 1
root@lab:~/scripts# cat /tmp/report.txt
Health check on lab at 2026-10-25 09:17:31
STATUS CHECK DETAIL
WARN disk / 5% used
WARN disk /root/scripts 61% used
OK memory 92% available (7221 MiB)
OK load 0.17 on 8 CPUs (2%)
OK process sleep 1 running
A failure exits 2:
root@lab:~/scripts# ./health-check.sh -d / -d /root/scripts -p sleep -p nginx; echo "exit $?"
STATUS CHECK DETAIL
OK disk / 5% used
OK disk /root/scripts 61% used
OK memory 92% available (7227 MiB)
OK load 0.17 on 8 CPUs (2%)
OK process sleep 1 running
FAIL process nginx not running
exit 2
A usage error exits 3, with a clear message on standard error:
root@lab:~/scripts# ./health-check.sh -s DISK_WARN=lots; echo "exit $?"
2026-10-25 09:17:31 health-check.sh: ERROR: DISK_WARN must be a whole number, got 'lots'
exit 3
root@lab:~/scripts# ./health-check.sh -c missing.conf; echo "exit $?"
2026-10-25 09:17:31 health-check.sh: ERROR: cannot read config file missing.conf
exit 3
The five-minute entry is installed and the every-minute one is gone:
root@lab:~/scripts# crontab -l | grep -c health-check
1
Going further
- Add a
-qoption that prints only the WARN and FAIL lines, for a quieter cron mail. - Add a check that a URL answers with
curl -fsS --max-time 5, graded FAIL when it does not. - Write the report as JSON with
printfas well, so another program can read it without parsing the table.