Linux System Health Check Script in Bash

Tested on RHEL 10.2 (Coughlan) — vm1.lab.example
Package bash-5.2.26-6.el10.x86_64
coreutils-9.5-8.el10_2.x86_64
systemd-257-23.el10_2.2.x86_64
jq-1.7.1-11.el10_2.2 (optional JSON mode)
Applies to Linux distributions with Bash installed
Privilege Normal user for most checks; reading all mount points and systemctl --failed may need elevated rights on hardened hosts
Scope One-time Linux health snapshot covering host identity, CPU utilization from /proc/stat, load averages in CPU context, available memory and swap, filesystem and inode use, failed systemd units, optional critical-service list, readable report, exit codes 0/1/2, optional JSON via jq, fixture-based threshold tests, and cron scheduling. Does not rank top CPU or memory processes or replace time-series monitoring.
Related guides Shell scripting course hub
Short on time?Beta Get the gist of this 23-minute read in seconds, then ask follow-up questions

A useful server health check needs more than raw uptime, free, and df output. This script turns CPU, load, memory, swap, disk, inode, and service state into OK, WARNING, CRITICAL, ERROR, or SKIP results with a predictable exit code.

Simply printing uptime, free, and df leaves the reader to decide what counts as unhealthy. This guide measures host-level CPU utilization and load as separate signals, uses available memory instead of naive used/total math, treats swap and inodes as their own checks, and only runs the systemd checks on hosts where systemd is available.


What "System Health" Means in This Script

This script reports a point-in-time view of host health. Each check becomes OK, WARNING, CRITICAL, ERROR, or SKIP, and those results determine the overall exit status.

Check What it measures What it does not measure
Host Hostname, kernel, uptime Hardware serial, cloud instance ID
CPU Host-wide utilization from /proc/stat Per-process CPU share
Load 1/5/15-minute load vs CPU count Same thing as CPU utilization
Memory MemAvailable as percent of total Per-process RSS (see dedicated guide)
Swap Swap used as percent of total Whether swap I/O is thrashing
Disk Block use and inode use per mount Growth rate over time
systemd Failed units, optional critical list Container health, remote dependencies

One snapshot cannot replace graphs or alerting on trends. Use this script for cron mail, pre-deploy gates, or a quick SSH sanity check. When you need who is consuming CPU or RAM, use the top memory and CPU processes walkthrough instead of duplicating ps logic here.


Define Thresholds and Exit Codes

Put thresholds in variables or a small config file so you can tune per environment without editing every comparison.

Variable Default Meaning
CPU_WARN_PCT / CPU_CRIT_PCT 80 / 95 Warn or fail when host CPU utilization is at or above this percent
MEM_WARN_PCT / MEM_CRIT_PCT 15 / 5 Warn or fail when available memory is at or below this percent of total
SWAP_WARN_PCT / SWAP_CRIT_PCT 50 / 80 Warn or fail when swap use is at or above this percent
LOAD_WARN_MULT / LOAD_CRIT_MULT 1.0 / 2.0 Warn or fail when 1-minute load is at or above this multiple of CPU count
DISK_WARN_PCT / DISK_CRIT_PCT 85 / 95 Filesystem block use at or above this whole-number percent
INODE_WARN_PCT / INODE_CRIT_PCT 85 / 95 Inode use at or above this whole-number percent on the same mount

Boundary comparisons are inclusive: exactly 80% CPU utilization triggers WARNING, exactly 85% disk use triggers WARNING, and exactly 15% available memory triggers WARNING.

Exit contract (aligned with Bash exit codes practice for monitoring scripts):

Code Meaning
0 All checks OK or SKIP
1 At least one WARNING or CRITICAL threshold breached
2 Invalid config, missing required tool, or a required check could not run

WARNING and CRITICAL both return 1 so cron, CI jobs, and simple monitoring wrappers can treat any unhealthy result as failure. Exit 2 is reserved for configuration or execution errors. The report text shows which level fired, so a wrapper can still read the detail even when the exit code is 1.

Example config file health.conf:

text
CPU_WARN_PCT=75
CPU_CRIT_PCT=90
MEM_WARN_PCT=20
MEM_CRIT_PCT=10
LOAD_WARN_MULT=1.5
LOAD_CRIT_MULT=3.0
CRITICAL_SERVICES="sshd,crond"

Load the file after parsing options:

bash
if [[ -n "${CONFIG_FILE}" && ! -f "${CONFIG_FILE}" ]]; then
  echo "Config file not found: ${CONFIG_FILE}" >&2
  exit 2
fi
[[ -f "${CONFIG_FILE}" ]] && source "${CONFIG_FILE}"

Only load configuration files you trust. source executes the file as Bash code; it is not a passive key/value parser.

Validate thresholds before any collector runs:

bash
validate_config() {
  # reject non-numeric values, out-of-range percentages,
  # integer-only disk/inode thresholds, and inverted warn/critical pairs
  ...
}
validate_config

A bad value such as MEM_WARN_PCT=abc should exit 2:

bash
./system-health.sh -c /tmp/bad-health.conf
output
Invalid threshold configuration: MEM_WARN_PCT=abc

Point -c at a missing file and the script should refuse to run:

bash
./system-health.sh -c /nonexistent.conf
output
Config file not found: /nonexistent.conf

The exit status is 2, which tells a wrapper the thresholds were never applied.


Collect Host and Uptime Information

Include the hostname, kernel version, and uptime so you can immediately tell which server a report came from.

Read uptime seconds from /proc/uptime and format a short human string:

bash
collect_host() {
  local hostname kernel uptime_secs days hours
  hostname="$(hostname -s 2>/dev/null || hostname)"
  kernel="$(uname -r)"
  uptime_secs="$(awk '{print int($1)}' /proc/uptime)"
  days=$((uptime_secs / 86400))
  hours=$(((uptime_secs % 86400) / 3600))
  printf '%s | kernel %s | up %dd %dh\n' "${hostname}" "${kernel}" "${days}" "${hours}"
}

On my lab host the collector produced:

output
vm1 | kernel 6.12.0-211.47.1.el10_2.x86_64 | up 0d 20h

Host facts are informational only; they do not change the exit code unless you add your own policy later.


Check CPU Utilization and Load Average

CPU utilization and load average are related, but they are not the same measurement. Utilization measures how busy the processors were during a sample window. Load counts runnable and uninterruptible tasks averaged over 1, 5, and 15 minutes. A healthy host can show moderate load with low utilization (many threads waiting on I/O) or high utilization with modest load (a few CPU-bound jobs). Measure both.

Host CPU utilization from /proc/stat

Read the aggregate cpu line from /proc/stat, sample twice with a one-second gap, and compute utilization from the delta:

bash
read_cpu_jiffies() {
  local cpu user nice system idle iowait irq softirq steal _rest
  read -r cpu user nice system idle iowait irq softirq steal _rest < /proc/stat
  local idle_all=$((idle + iowait))
  local non_idle=$((user + nice + system + irq + softirq + steal))
  local total=$((idle_all + non_idle))
  printf '%s %s\n' "${idle_all}" "${total}"
}

After two samples:

bash
cpu_usage_pct = 100 * (total_delta - idle_delta) / total_delta

Compare the result to CPU_WARN_PCT (default 80) and CPU_CRIT_PCT (default 95). On my lab host the one-second sample reported:

output
utilization 23.6%

With an 80% warning threshold, this sample remains OK. This is host-wide utilization, not per-process ranking.

This delta calculation is suitable for a lightweight health snapshot, but /proc/stat CPU accounting — especially iowait — has kernel-documented limitations, so do not treat the percentage as precision performance telemetry.

Load averages in CPU context

Collect CPU count and the three load figures together:

bash
collect_cpu_load() {
  local cpus load1 load5 load15
  cpus="$(nproc)"
  read -r load1 load5 load15 _ < /proc/loadavg
  printf 'cpus=%s load=%s/%s/%s (1/5/15m)\n' "${cpus}" "${load1}" "${load5}" "${load15}"
}

Threshold logic multiplies CPU count by your warning and critical multipliers, then compares only the 1-minute figure:

bash
warn_thresh="$(awk -v c="${cpus}" -v m="${LOAD_WARN_MULT}" 'BEGIN{printf "%.2f", c*m}')"
crit_thresh="$(awk -v c="${cpus}" -v m="${LOAD_CRIT_MULT}" 'BEGIN{printf "%.2f", c*m}')"

On the same quiet three-CPU VM I saw:

output
cpus=3 load=0.34/0.21/0.20 (1/5/15m)

With defaults (1.0× warn, 2.0× crit), thresholds are 3.00 and 6.00. Both CPU utilization and load stayed OK. Checking them separately helps distinguish high CPU utilization from a high load average.


Check Available Memory and Swap

Do not score memory with (total - used) / total from the first line of free. Linux can reclaim cache; MemAvailable in /proc/meminfo (and the "available" column in free -h) estimates what new workloads can still allocate.

bash
mem_avail_kb="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)"
mem_total_kb="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)"
avail_pct="$(awk -v a="${mem_avail_kb}" -v t="${mem_total_kb}" \
  'BEGIN{if(t>0) printf "%.1f", (a/t)*100; else print 0}')"

Swap is a separate check. Swap usage shows how much configured swap currently contains pages, but high usage alone does not prove the system is under memory pressure now. Treat it as a separate signal alongside MemAvailable; diagnosing active swapping requires I/O or paging-rate metrics.

bash
swap_total_kb="$(awk '/^SwapTotal:/ {print $2}' /proc/meminfo)"
swap_used_kb="$(awk '/^SwapTotal:|^SwapFree:/ {
  if ($1=="SwapTotal:") t=$2; if ($1=="SwapFree:") f=$2
} END {print t-f}' /proc/meminfo)"

Lab output for the combined memory line:

output
available 65.7% (5164380 KiB of 7863248 KiB); swap 8.8% used

Available memory is well above its 15% warning floor, while swap usage is below its 50% warning threshold, so the combined memory check remains OK. When SwapTotal is zero, report swap not configured instead of swap 0% used. If MemAvailable falls to or below MEM_CRIT_PCT (default 5%), mark memory CRITICAL even when the legacy "used" bar in free still looks tolerable.


Check Disk Space and Inodes

Full disks and exhausted inode tables fail in different ways. A small-file mail spool can hit inode limits while df -h still shows free gigabytes. Run both checks per mount.

Skip pseudo filesystems you do not want in a server report, then collect the mount list before looping. If enumeration fails, record ERROR and exit 2 instead of reporting an empty OK:

bash
if ! mounts="$(df -P -x tmpfs -x devtmpfs -x squashfs 2>/dev/null |
              awk 'NR>1 { print $6 }')"; then
  set_check disk ERROR "unable to enumerate filesystems"
  return 0
fi

For each remaining mount, read block percent and inode percent. Handle df failures explicitly so set -e does not terminate the script before the report is printed:

bash
if ! use_pct="$(df -P "${mount}" 2>/dev/null |
    awk 'NR==2 { gsub(/%/,"",$5); print $5 }')"; then
  set_check disk ERROR "unable to read block usage for ${mount}"
  return 0
fi

if ! inode_pct="$(df -Pi "${mount}" 2>/dev/null |
    awk 'NR==2 { gsub(/%/,"",$5); print $5 }')"; then
  set_check disk ERROR "unable to read inode usage for ${mount}"
  return 0
fi

Validate that both values are numeric before comparing them to thresholds. GNU df returns non-zero on failure, so a mount that disappears between enumeration and inspection should produce [ERROR] disk: ... and exit 2, not a silent abort.

On my lab host the disk summary looked like:

output
/: 63% used, 3% inodes; /boot: 33% used, 1% inodes

Either metric crossing DISK_WARN_PCT or INODE_WARN_PCT marks that mount at WARNING; critical thresholds default to 95%.


Check Failed systemd Units

Failed units are systemd-specific. Do not use systemctl is-system-running as a gate: a degraded system returns non-zero precisely because units failed, and you would skip the check you need most.

Check that systemctl exists and that the systemd runtime directory is present before running systemd-specific checks:

bash
systemd_available() {
  command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]
}

collect_failed_units() {
  local output f
  if ! systemd_available; then
    echo "SKIP|systemd not available"
    return
  fi
  if ! output="$(systemctl --failed --no-legend --no-pager 2>&1)"; then
    printf 'ERROR|unable to query failed systemd units: %s\n' "${output}"
    return
  fi
  f="$(printf '%s\n' "${output}" | awk 'NF {print $1}' | paste -sd, -)"
  [[ -z "${f}" ]] && echo "OK|" || echo "FAIL|${f}"
}

The systemctl command cheat sheet covers unit states; here you only need the failed list. An empty result means OK. Any unit name in that list marks the check CRITICAL. If systemctl --failed itself fails, record an ERROR result and exit 2 instead of letting set -e terminate the script without a report.

Optional critical services (-s sshd,nginx) reuse the same systemd_available() guard before calling systemctl is-active. Treat the textual state is-active prints (inactive, failed, and similar) as a policy failure, and reserve ERROR for cases where systemd could not be queried at all. A stopped but enabled web server might not appear under --failed yet still violates your policy.

bash
./system-health.sh -s sshd
output
[OK] critical_services: all critical units active

Pass a unit that is not running and the check names it in the report with exit 1.


Aggregate Overall Status

Keep per-check results in parallel arrays or an associative map, then walk them once:

bash
set_check() {
  local name="$1" level="$2" detail="$3"
  CHECK_STATUS["${name}"]="${level}"
  CHECK_DETAIL["${name}"]="${detail}"
  case "${level}" in
    ERROR)
      overall_status="ERROR"
      overall_exit="${EXIT_CONFIG}"
      ;;
    CRITICAL)
      if [[ "${overall_status}" != "ERROR" ]]; then
        overall_status="CRITICAL"
      fi
      if [[ "${overall_exit}" -ne "${EXIT_CONFIG}" ]]; then
        overall_exit="${EXIT_PROBLEM}"
      fi
      ;;
    WARNING)
      if [[ "${overall_status}" == "OK" ]]; then
        overall_status="WARNING"
      fi
      if [[ "${overall_exit}" -eq "${EXIT_OK}" ]]; then
        overall_exit="${EXIT_PROBLEM}"
      fi
      ;;
  esac
  return 0
}

set_check() records each result and updates the overall status. The script returns the final exit code only after all available checks have run.

Precedence is ERROR > CRITICAL > WARNING > OK/SKIP. An ERROR sets overall status to ERROR and exit 2. Later threshold breaches do not downgrade that. SKIP does not change the exit code (for example when systemd is absent).

Run collectors, then evaluators, in a fixed order so the text report is stable: host, CPU, load, memory, disk, failed units, critical services.


Produce a Readable Report

Human output is the default. Print overall status first, then one line per check with the level in brackets:

bash
./system-health.sh
output
=== System health snapshot ===
Overall: OK (exit 0)

[OK] host: vm1 | kernel 6.12.0-211.47.1.el10_2.x86_64 | up 0d 20h
[OK] cpu: utilization 23.6%
[OK] load: cpus=3 load=0.34/0.21/0.20 (1/5/15m)
[OK] memory: available 56.9% (4474036 KiB of 7863248 KiB); swap 8.8% used
[OK] disk: /: 63% used, 3% inodes; /boot: 33% used, 1% inodes
[OK] systemd_failed: no failed units
[SKIP] critical_services: not configured

When a threshold fires, the bracketed level changes to WARNING or CRITICAL and the detail line states which threshold was crossed. A scheduler or monitoring hook can grep for CRITICAL or react to the non-zero exit status.


Optional Machine-Readable JSON Output

For log aggregators or CI gates, add -j and build JSON with one jq -n call. Pass each status and detail string through --arg so jq handles escaping. That pattern matches the safe construction style in Parse JSON in Bash with jq.

bash
./system-health.sh -j
output
{
  "overall": "OK",
  "exit_code": 0,
  "checks": {
    "host": {
      "status": "OK",
      "detail": "vm1 | kernel 6.12.0-211.47.1.el10_2.x86_64 | up 0d 20h"
    },
    "cpu": {
      "status": "OK",
      "detail": "utilization 32.6%"
    },
    "load": {
      "status": "OK",
      "detail": "cpus=3 load=0.31/0.20/0.20 (1/5/15m)"
    },
    "memory": {
      "status": "OK",
      "detail": "available 56.9% (4473028 KiB of 7863248 KiB); swap 8.8% used"
    },
    "disk": {
      "status": "OK",
      "detail": "/: 63% used, 3% inodes; /boot: 33% used, 1% inodes"
    },
    "systemd_failed": {
      "status": "OK",
      "detail": "no failed units"
    },
    "critical_services": {
      "status": "SKIP",
      "detail": "not configured"
    }
  }
}

If jq is not installed, print a clear error and exit 2. Parsers should read exit_code from JSON and still verify $? after the script exits.


Test Threshold Logic with Fixtures

Do not burn CPU or fill disks to test warnings. Split collectors (read real /proc) from evaluators (compare numbers to thresholds). Unit-test the evaluators by passing fake pipe-delimited fixture strings.

Create test-thresholds.sh that sources the evaluator functions and asserts expected levels:

bash
./test-thresholds.sh
output
PASS cpu -> OK
PASS cpu -> WARNING
PASS cpu -> WARNING
PASS cpu -> CRITICAL
PASS load -> OK
PASS load -> OK
PASS load -> WARNING
PASS load -> CRITICAL
PASS memory -> OK
PASS memory -> WARNING
PASS memory -> WARNING
PASS memory -> CRITICAL
PASS memory -> CRITICAL
---
Passed: 13 Failed: 0

Thirteen passing assertions confirm CPU, load, and memory math without touching live resources, including exact-boundary cases such as 80% CPU and 4.0 load on four CPUs. Add disk and systemd evaluators the same way when you extend the script.


Schedule the Report

Install the script on each server, then call it from cron or a systemd timer. Scheduled jobs need absolute paths, a minimal PATH, and log redirection; see Schedule Bash scripts for PATH, flock, and timer units.

Example user crontab line (daily at 07:00, append to a log in your home directory):

bash
0 7 * * * /usr/local/bin/system-health.sh >>"$HOME/system-health.log" 2>&1

A normal user cannot write to /var/log without a pre-created path and permissions. Point the redirect somewhere you own, or use a root crontab with a dedicated log file. External monitoring can react to the non-zero exit status without mailing from cron.


Complete Modular Script

The full script combines option parsing, config load, threshold validation, collectors, evaluators, text and JSON reporters, and a final exit "${overall_exit}". Save it as system-health.sh, mark executable, and place thresholds in a config file you trust on production hosts.

bash
#!/usr/bin/env bash
set -euo pipefail

CPU_WARN_PCT=80
CPU_CRIT_PCT=95
MEM_WARN_PCT=15
MEM_CRIT_PCT=5
SWAP_WARN_PCT=50
SWAP_CRIT_PCT=80
LOAD_WARN_MULT=1.0
LOAD_CRIT_MULT=2.0
DISK_WARN_PCT=85
DISK_CRIT_PCT=95
INODE_WARN_PCT=85
INODE_CRIT_PCT=95
CRITICAL_SERVICES=""
OUTPUT_FORMAT="text"
CONFIG_FILE=""

EXIT_OK=0
EXIT_PROBLEM=1
EXIT_CONFIG=2

overall_status="OK"
overall_exit="${EXIT_OK}"
declare -A CHECK_STATUS CHECK_DETAIL

usage() { echo "Usage: $0 [-c FILE] [-j] [-s units] [-h]"; }

while getopts ":c:js:h" opt; do
  case "${opt}" in
    c) CONFIG_FILE="${OPTARG}" ;;
    j) OUTPUT_FORMAT="json" ;;
    s) CRITICAL_SERVICES="${OPTARG}" ;;
    h) usage; exit 0 ;;
    \?) echo "Invalid option: -${OPTARG}" >&2; exit "${EXIT_CONFIG}" ;;
    :) echo "Option -${OPTARG} requires an argument." >&2; exit "${EXIT_CONFIG}" ;;
  esac
done

if [[ -n "${CONFIG_FILE}" ]]; then
  [[ -f "${CONFIG_FILE}" ]] || { echo "Config file not found: ${CONFIG_FILE}" >&2; exit "${EXIT_CONFIG}"; }
  # shellcheck source=/dev/null
  source "${CONFIG_FILE}"
fi

validate_config() {
  local key val
  for key in CPU_WARN_PCT CPU_CRIT_PCT MEM_WARN_PCT MEM_CRIT_PCT SWAP_WARN_PCT SWAP_CRIT_PCT; do
    val="${!key}"
    [[ "${val}" =~ ^[0-9]+([.][0-9]+)?$ ]] || { echo "Invalid threshold configuration: ${key}=${val}" >&2; exit "${EXIT_CONFIG}"; }
    awk -v v="${val}" 'BEGIN{exit !(v>=0 && v<=100)}' || { echo "Invalid threshold configuration: ${key}=${val}" >&2; exit "${EXIT_CONFIG}"; }
  done
  for key in DISK_WARN_PCT DISK_CRIT_PCT INODE_WARN_PCT INODE_CRIT_PCT; do
    val="${!key}"
    [[ "${val}" =~ ^[0-9]+$ ]] || { echo "Invalid threshold configuration: ${key}=${val}" >&2; exit "${EXIT_CONFIG}"; }
    awk -v v="${val}" 'BEGIN{exit !(v>=0 && v<=100)}' || { echo "Invalid threshold configuration: ${key}=${val}" >&2; exit "${EXIT_CONFIG}"; }
  done
  for key in LOAD_WARN_MULT LOAD_CRIT_MULT; do
    val="${!key}"
    [[ "${val}" =~ ^[0-9]+([.][0-9]+)?$ ]] || { echo "Invalid threshold configuration: ${key}=${val}" >&2; exit "${EXIT_CONFIG}"; }
    awk -v v="${val}" 'BEGIN{exit !(v>0)}' || { echo "Invalid threshold configuration: ${key}=${val}" >&2; exit "${EXIT_CONFIG}"; }
  done
  awk -v w="${MEM_WARN_PCT}" -v c="${MEM_CRIT_PCT}" 'BEGIN{exit !(w>c)}' || { echo "Invalid threshold configuration: MEM_WARN_PCT must exceed MEM_CRIT_PCT" >&2; exit "${EXIT_CONFIG}"; }
  awk -v w="${SWAP_WARN_PCT}" -v c="${SWAP_CRIT_PCT}" 'BEGIN{exit !(c>w)}' || { echo "Invalid threshold configuration: SWAP_CRIT_PCT must exceed SWAP_WARN_PCT" >&2; exit "${EXIT_CONFIG}"; }
  awk -v w="${CPU_WARN_PCT}" -v c="${CPU_CRIT_PCT}" 'BEGIN{exit !(c>w)}' || { echo "Invalid threshold configuration: CPU_CRIT_PCT must exceed CPU_WARN_PCT" >&2; exit "${EXIT_CONFIG}"; }
  awk -v w="${DISK_WARN_PCT}" -v c="${DISK_CRIT_PCT}" 'BEGIN{exit !(c>w)}' || { echo "Invalid threshold configuration: DISK_CRIT_PCT must exceed DISK_WARN_PCT" >&2; exit "${EXIT_CONFIG}"; }
  awk -v w="${INODE_WARN_PCT}" -v c="${INODE_CRIT_PCT}" 'BEGIN{exit !(c>w)}' || { echo "Invalid threshold configuration: INODE_CRIT_PCT must exceed INODE_WARN_PCT" >&2; exit "${EXIT_CONFIG}"; }
  awk -v w="${LOAD_WARN_MULT}" -v c="${LOAD_CRIT_MULT}" 'BEGIN{exit !(c>w)}' || { echo "Invalid threshold configuration: LOAD_CRIT_MULT must exceed LOAD_WARN_MULT" >&2; exit "${EXIT_CONFIG}"; }
}

validate_config

set_check() {
  local name="$1" level="$2" detail="$3"
  CHECK_STATUS["${name}"]="${level}"
  CHECK_DETAIL["${name}"]="${detail}"
  case "${level}" in
    ERROR)
      overall_status="ERROR"
      overall_exit="${EXIT_CONFIG}"
      ;;
    CRITICAL)
      if [[ "${overall_status}" != "ERROR" ]]; then
        overall_status="CRITICAL"
      fi
      if [[ "${overall_exit}" -ne "${EXIT_CONFIG}" ]]; then
        overall_exit="${EXIT_PROBLEM}"
      fi
      ;;
    WARNING)
      if [[ "${overall_status}" == "OK" ]]; then
        overall_status="WARNING"
      fi
      if [[ "${overall_exit}" -eq "${EXIT_OK}" ]]; then
        overall_exit="${EXIT_PROBLEM}"
      fi
      ;;
  esac
  return 0
}

systemd_available() {
  command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]
}

read_cpu_jiffies() {
  local cpu user nice system idle iowait irq softirq steal _rest
  read -r cpu user nice system idle iowait irq softirq steal _rest < /proc/stat
  local idle_all=$((idle + iowait))
  local non_idle=$((user + nice + system + irq + softirq + steal))
  local total=$((idle_all + non_idle))
  printf '%s %s\n' "${idle_all}" "${total}"
}

collect_cpu_util() {
  local idle1 total1 idle2 total2 idle_d total_d
  read -r idle1 total1 < <(read_cpu_jiffies)
  sleep 1
  read -r idle2 total2 < <(read_cpu_jiffies)
  idle_d=$((idle2 - idle1))
  total_d=$((total2 - total1))
  if [[ "${total_d}" -le 0 ]]; then
    echo "CONFIG_ERROR|/proc/stat delta invalid"
    return 0
  fi
  awk -v id="${idle_d}" -v td="${total_d}" 'BEGIN{printf "%.1f", (100*(td-id)/td)}'
}

collect_cpu_load() {
  local cpus load1 load5 load15
  cpus="$(nproc)"
  read -r load1 load5 load15 _ < /proc/loadavg
  printf '%s|%s|%s|%s' "${cpus}" "${load1}" "${load5}" "${load15}"
}

collect_host() {
  local h k u d hr
  h="$(hostname -s 2>/dev/null || hostname)"
  k="$(uname -r)"
  u="$(awk '{print int($1)}' /proc/uptime)"
  d=$((u / 86400)); hr=$(((u % 86400) / 3600))
  printf '%s|%s|%sd %sh' "${h}" "${k}" "${d}" "${hr}"
}

collect_memory() {
  awk '/^MemAvailable:|^MemTotal:|^SwapTotal:|^SwapFree:/ {
    if ($1=="MemAvailable:") a=$2; if ($1=="MemTotal:") t=$2
    if ($1=="SwapTotal:") st=$2; if ($1=="SwapFree:") sf=$2
  } END {printf "%s|%s|%s|%s", a,t,st-sf,st}' /proc/meminfo
}

eval_host() {
  IFS='|' read -r h k up <<< "$1"
  set_check host OK "${h} | kernel ${k} | up ${up}"
}

eval_cpu() {
  local util="$1"
  if [[ "${util}" == CONFIG_ERROR* ]]; then
    set_check cpu ERROR "${util#*|}"
    return 0
  fi
  local lvl=OK det="utilization ${util}%"
  if awk -v u="${util}" -v c="${CPU_CRIT_PCT}" 'BEGIN{exit !(u>=c)}'; then
    lvl=CRITICAL
    det+=" — at or above ${CPU_CRIT_PCT}%"
  elif awk -v u="${util}" -v w="${CPU_WARN_PCT}" 'BEGIN{exit !(u>=w)}'; then
    lvl=WARNING
    det+=" — at or above ${CPU_WARN_PCT}%"
  fi
  set_check cpu "${lvl}" "${det}"
}

eval_load() {
  IFS='|' read -r c l1 l5 l15 <<< "$1"
  local w cth lvl=OK det="cpus=${c} load=${l1}/${l5}/${l15} (1/5/15m)"
  w="$(awk -v c="${c}" -v m="${LOAD_WARN_MULT}" 'BEGIN{printf "%.2f",c*m}')"
  cth="$(awk -v c="${c}" -v m="${LOAD_CRIT_MULT}" 'BEGIN{printf "%.2f",c*m}')"
  if awk -v l="${l1}" -v t="${cth}" 'BEGIN{exit !(l>=t)}'; then
    lvl=CRITICAL
    det+=" — 1m at or above ${cth}"
  elif awk -v l="${l1}" -v t="${w}" 'BEGIN{exit !(l>=t)}'; then
    lvl=WARNING
    det+=" — 1m at or above ${w}"
  fi
  set_check load "${lvl}" "${det}"
}

eval_memory() {
  IFS='|' read -r a t su st <<< "$1"
  local ap sp=0 ml=OK sl=OK comb swap_detail
  ap="$(awk -v a="${a}" -v t="${t}" 'BEGIN{printf "%.1f",(a/t)*100}')"
  if [[ "${st}" -gt 0 ]]; then
    sp="$(awk -v u="${su}" -v t="${st}" 'BEGIN{printf "%.1f",(u/t)*100}')"
    swap_detail="swap ${sp}% used"
    if awk -v p="${sp}" -v c="${SWAP_CRIT_PCT}" 'BEGIN{exit !(p>=c)}'; then
      sl=CRITICAL
    elif awk -v p="${sp}" -v w="${SWAP_WARN_PCT}" 'BEGIN{exit !(p>=w)}'; then
      sl=WARNING
    fi
  else
    sl=SKIP
    swap_detail="swap not configured"
  fi
  if awk -v p="${ap}" -v c="${MEM_CRIT_PCT}" 'BEGIN{exit !(p<=c)}'; then
    ml=CRITICAL
  elif awk -v p="${ap}" -v w="${MEM_WARN_PCT}" 'BEGIN{exit !(p<=w)}'; then
    ml=WARNING
  fi
  comb="${ml}"
  if [[ "${sl}" == CRITICAL ]]; then
    comb=CRITICAL
  elif [[ "${sl}" == WARNING && "${comb}" == OK ]]; then
    comb=WARNING
  fi
  set_check memory "${comb}" "available ${ap}% (${a} KiB of ${t} KiB); ${swap_detail}"
}

eval_disk_all() {
  local worst=OK details="" m u i lvl note mounts
  if ! mounts="$(df -P -x tmpfs -x devtmpfs -x squashfs 2>/dev/null |
                awk 'NR>1 { print $6 }')"; then
    set_check disk ERROR "unable to enumerate filesystems"
    return 0
  fi
  if [[ -z "${mounts}" ]]; then
    set_check disk ERROR "no filesystems found to check"
    return 0
  fi
  while IFS= read -r m; do
    if [[ -z "${m}" ]]; then
      continue
    fi
    if ! u="$(df -P "${m}" 2>/dev/null | awk 'NR==2 { gsub(/%/,"",$5); print $5 }')"; then
      set_check disk ERROR "unable to read block usage for ${m}"
      return 0
    fi
    if ! i="$(df -Pi "${m}" 2>/dev/null | awk 'NR==2 { gsub(/%/,"",$5); print $5 }')"; then
      set_check disk ERROR "unable to read inode usage for ${m}"
      return 0
    fi
    if [[ -z "${u}" || ! "${u}" =~ ^[0-9]+$ ]]; then
      set_check disk ERROR "invalid block usage value for ${m}"
      return 0
    fi
    if [[ -z "${i}" || ! "${i}" =~ ^[0-9]+$ ]]; then
      set_check disk ERROR "invalid inode usage value for ${m}"
      return 0
    fi
    lvl=OK
    note="${m}: ${u}% used, ${i}% inodes"
    if [[ "${u}" -ge "${DISK_CRIT_PCT}" || "${i}" -ge "${INODE_CRIT_PCT}" ]]; then
      lvl=CRITICAL
      note+=" — critical"
    elif [[ "${u}" -ge "${DISK_WARN_PCT}" || "${i}" -ge "${INODE_WARN_PCT}" ]]; then
      lvl=WARNING
      note+=" — warning"
    fi
    if [[ "${lvl}" == CRITICAL ]]; then
      worst=CRITICAL
    elif [[ "${lvl}" == WARNING && "${worst}" == OK ]]; then
      worst=WARNING
    fi
    details+="${note}; "
  done <<< "${mounts}"
  set_check disk "${worst}" "${details%; }"
}

collect_failed_units() {
  if ! systemd_available; then
    echo "SKIP|systemd not available"
    return 0
  fi
  local output f
  if ! output="$(systemctl --failed --no-legend --no-pager 2>&1)"; then
    output="${output//$'\n'/ }"
    printf 'ERROR|unable to query failed systemd units: %s\n' "${output}"
    return 0
  fi
  f="$(printf '%s\n' "${output}" | awk 'NF {print $1}' | paste -sd, -)"
  if [[ -z "${f}" ]]; then
    echo "OK|"
  else
    echo "FAIL|${f}"
  fi
}

eval_failed_units() {
  IFS='|' read -r st det <<< "$1"
  case "${st}" in
    SKIP) set_check systemd_failed SKIP "${det}" ;;
    OK) set_check systemd_failed OK "no failed units" ;;
    FAIL) set_check systemd_failed CRITICAL "failed: ${det}" ;;
    ERROR) set_check systemd_failed ERROR "${det}" ;;
  esac
}

collect_critical_services() {
  if [[ -z "${CRITICAL_SERVICES}" ]]; then
    echo "SKIP|"
    return 0
  fi
  if ! systemd_available; then
    echo "SKIP|systemd not available"
    return 0
  fi
  local u output bad=""
  IFS=',' read -ra units <<< "${CRITICAL_SERVICES}"
  for u in "${units[@]}"; do
    u="${u// /}"
    if [[ -z "${u}" ]]; then
      continue
    fi
    if output="$(systemctl is-active "${u}" 2>&1)"; then
      continue
    fi
    output="${output//$'\n'/ }"
    case "${output}" in
      inactive|failed|activating|deactivating|maintenance|reloading|refreshing)
        bad+="${u}:${output},"
        ;;
      *)
        printf 'ERROR|unable to query unit %s: %s\n' "${u}" "${output}"
        return 0
        ;;
    esac
  done
  bad="${bad%,}"
  if [[ -z "${bad}" ]]; then
    echo "OK|"
  else
    echo "FAIL|${bad}"
  fi
}

eval_critical_services() {
  IFS='|' read -r st det <<< "$1"
  if [[ -z "${det}" ]]; then
    det="not configured"
  fi
  case "${st}" in
    SKIP) set_check critical_services SKIP "${det}" ;;
    OK) set_check critical_services OK "all critical units active" ;;
    FAIL) set_check critical_services CRITICAL "inactive: ${det}" ;;
    ERROR) set_check critical_services ERROR "${det}" ;;
  esac
}

print_text_report() {
  echo "=== System health snapshot ==="
  echo "Overall: ${overall_status} (exit ${overall_exit})"
  echo
  for n in host cpu load memory disk systemd_failed critical_services; do
    printf '[%s] %s: %s\n' "${CHECK_STATUS[$n]:-SKIP}" "${n}" "${CHECK_DETAIL[$n]:-}"
  done
}

print_json_report() {
  command -v jq >/dev/null || { echo "jq is required for JSON output" >&2; exit "${EXIT_CONFIG}"; }
  jq -n \
    --arg overall "${overall_status}" \
    --argjson exit_code "${overall_exit}" \
    --arg hs "${CHECK_STATUS[host]}" --arg hd "${CHECK_DETAIL[host]}" \
    --arg cs "${CHECK_STATUS[cpu]}" --arg cd "${CHECK_DETAIL[cpu]}" \
    --arg ls "${CHECK_STATUS[load]}" --arg ld "${CHECK_DETAIL[load]}" \
    --arg ms "${CHECK_STATUS[memory]}" --arg md "${CHECK_DETAIL[memory]}" \
    --arg ds "${CHECK_STATUS[disk]}" --arg dd "${CHECK_DETAIL[disk]}" \
    --arg ss "${CHECK_STATUS[systemd_failed]}" --arg sd "${CHECK_DETAIL[systemd_failed]}" \
    --arg ks "${CHECK_STATUS[critical_services]}" --arg kd "${CHECK_DETAIL[critical_services]}" \
    '{overall:$overall, exit_code:$exit_code, checks:{
      host:{status:$hs,detail:$hd}, cpu:{status:$cs,detail:$cd},
      load:{status:$ls,detail:$ld}, memory:{status:$ms,detail:$md},
      disk:{status:$ds,detail:$dd}, systemd_failed:{status:$ss,detail:$sd},
      critical_services:{status:$ks,detail:$kd}}}'
}

eval_host "$(collect_host)"
eval_cpu "$(collect_cpu_util)"
eval_load "$(collect_cpu_load)"
eval_memory "$(collect_memory)"
eval_disk_all
eval_failed_units "$(collect_failed_units)"
eval_critical_services "$(collect_critical_services)"

if [[ "${OUTPUT_FORMAT}" == json ]]; then
  print_json_report
else
  print_text_report
fi
exit "${overall_exit}"

The complete script above was tested on the RHEL 10.2 host listed at the beginning of this article. Adjust thresholds in health.conf rather than editing the script body.


What This Script Cannot Replace

A snapshot script complements monitoring; it does not replace it.

Gap Use instead
CPU and RAM per process Dedicated process-ranking script (see body above)
Historical trends, percentiles, SLOs Prometheus, Datadog, Zabbix, or cloud vendor metrics
Log-based errors Centralized logging and alert rules on log volume
Network, DNS, TLS expiry Dedicated synthetic checks
Container/pod health Orchestrator probes and CNI-aware tooling

Treat exit 1 as "investigate now" on that host, not as a root-cause diagnosis. The report tells you which check failed, but not the root cause.


Summary

The script produces a point-in-time health snapshot from /proc, df, and systemd: host-wide CPU utilization, load averages, available memory, swap use, filesystem and inode usage, and failed units. Each line is scored against configurable thresholds.

CPU utilization and load average are separate checks with separate meanings. Memory decisions use MemAvailable, swap is scored separately, and inode exhaustion is checked independently of block use. The script exits 0 when healthy, 1 when a threshold fires, and 2 when configuration or required tools are wrong.

Fixture tests let you validate threshold math without stressing the machine. Schedule the same script from cron or a systemd timer and optionally emit JSON with jq for downstream automation. For historical trends and alerting, use this script alongside a proper monitoring system rather than trying to add those features to a one-time Bash health check.


References


Frequently Asked Questions

Is load average the same as CPU usage?

No. Load average counts runnable and uninterruptible tasks over time. CPU utilization measures how busy processors were during a sample window. This script checks both separately: utilization from /proc/stat deltas and load from /proc/loadavg compared with thresholds based on the CPU count.

Which memory value should I monitor?

Use MemAvailable from /proc/meminfo or the "available" column from free. It reflects memory the kernel could give new workloads without swapping. Simple used / total from free ignores reclaimable cache and often looks worse than reality.

How do I detect failed systemd units?

Run systemctl --failed when systemctl is available and /run/systemd/system exists. Do not gate the check on systemctl is-system-running, because a degraded system can return non-zero for that command while failed units still need reporting. Optionally verify named critical units with systemctl is-active even when they do not appear in the failed list.

Should a health check return non-zero?

Yes, when you want schedulers or monitoring hooks to act automatically. Return 0 for healthy, 1 for threshold breaches, and 2 for script or configuration errors. Keep warning and critical distinct in the report text even if both use exit 1.

Is this a replacement for node_exporter or monitoring?

No. This script is a single-point snapshot suitable for cron mail, quick SSH checks, or a simple gate. Time-series metrics, dashboards, and alert routing need a monitoring agent or service such as Prometheus node_exporter, Datadog, or your cloud provider's metrics platform.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)