Why This Script Exists

You've got Prometheus, Grafana, Loki, and Alertmanager running. Dashboards look beautiful. Alerts fire when thresholds are breached. So why do you need a health check email?

Because dashboards are reactive.

Health checks are proactive. They give you a snapshot of everything — CPU, memory, disk, Docker containers, failed systemd units, SSH brute force attempts — in one HTML email that lands in your inbox. No login required. No clicking through panels. Just a subject line that says "All Good" or screams "Something's Wrong."

I built healthcheck.sh as a lightweight, standalone alternative to full monitoring stacks. It's one bash script, runs via cron, sends a nicely formatted email report. Perfect for when you want to know your VPS is alive without opening a browser.

Here's what we'll cover:

  • What metrics the script collects
  • The full code walkthrough
  • How to set it up and customize it

What the Script Monitors

The script collects 15+ categories of system metrics:

Category What It Checks Thresholds
CPU Usage %, user/system split, I/O wait 70% warn, 90% critical
Memory Total/used/available, swap usage 75% warn, 90% critical
Disk Root partition usage, inode usage 75% warn, 90% critical
Network RX/TX bytes, errors, drops Any error = critical
Docker Running/stopped containers, healthy/unhealthy Any unhealthy = critical
Systemd Failed services count + names Any failure = critical
Processes Total count, zombie processes Any zombie = critical
Top Consumers Top 5 CPU and memory consumers Informational
Security SSH failed attempts (24h), OOM kills 10 warn, 50 critical
System Hostname, OS, kernel, uptime, load Informational

Each metric gets color-coded (green/yellow/red) in the email, so you can scan the report in 2 seconds and know if anything needs attention.


Code Walkthrough

Let's break down the script section by section.

Configuration and Setup

#!/bin/bash
# Health Check Monitoring Script
# Sends HTML email report via smtp-relay:587

set -euo pipefail

# --- Configuration ---
SMTP_HOST="172.19.0.9"
SMTP_PORT="587"
FROM="[email protected]"
TO="[email protected]"
MY_HOSTNAME=$(hostname -f)
REPORT_DATE=$(date '+%Y-%m-%d %H:%M:%S %Z')

Standard bash setup with set -euo pipefail for safety. Configuration block at the top — change SMTP_HOST, FROM, and TO for your setup. The SMTP host is a local Docker container (smtp-relay), not a remote server.

Helper Functions

# --- Color Codes ---
GREEN="#2ecc71"
YELLOW="#f39c12"
RED="#e74c3c"
GRAY="#95a5a6"

pct_color() {
    local val=$1 warn=$2 crit=$3
    awk "BEGIN{v=$val; w=$warn; c=$crit; if(v>c) print \"$RED\"; else if(v>w) print \"$YELLOW\"; else print \"$GREEN\"}"
}

pct_status() {
    local val=$1 warn=$2 crit=$3
    awk "BEGIN{v=$val; w=$warn; c=$crit; if(v>c) print \"CRITICAL\"; else if(v>w) print \"WARNING\"; else print \"OK\"}"
}

bytes_human() {
    local b=$1
    awk "BEGIN{b=$b; if(b>1073741824) printf \"%.2f GB\",b/1073741824; else if(b>1048576) printf \"%.2f MB\",b/1048576; else if(b>1024) printf \"%.2f KB\",b/1024; else print b\" B\"}"
}

Three utility functions that do the heavy lifting:

  • pct_color — returns HTML color based on value against warning/critical thresholds
  • pct_status — returns status string ("OK", "WARNING", "CRITICAL")
  • bytes_human — converts raw bytes to human-readable format (KB/MB/GB)
row() {
    echo "<tr><td style='padding:8px;border-bottom:1px solid #ddd;'>$1</td><td style='padding:8px;border-bottom:1px solid #ddd;color:${3:-$GREEN};font-weight:bold;'>$2</td></tr>"
}

section() {
    echo "<tr><td colspan='2' style='padding:12px 8px 8px;background:#34495e;color:white;font-weight:bold;font-size:14px;border-radius:4px 4px 0 0;'>$1</td></tr>"
}

HTML table helpers. row() creates a two-column table row with optional color. section() creates a dark header row for each category.

Metric Collection

# System
UPTIME_STR=$(uptime -p 2>/dev/null || uptime)
LOAD_STR=$(cat /proc/loadavg | awk '{print $1" / "$2" / "$3}')
CORES=$(nproc)
KERNEL=$(uname -r)
OS=$(grep PRETTY_NAME /etc/os-release | cut -d'"' -f2)

Basic system info. The || uptime fallback handles systems where uptime -p isn't available.

# CPU
read CPU_USER CPU_SYS CPU_IDLE CPU_IOW <<< $(top -bn1 | grep "%Cpu" | sed 's/%Cpu(s)://' | awk '{
    for(i=1;i<=NF;i++){
        gsub(/,/,"",$i)
        if($i=="us")u=$(i-1)
        if($i=="sy")s=$(i-1)
        if($i=="id")d=$(i-1)
        if($i=="wa")w=$(i-1)
    }
    printf "%s %s %s %s",u,s,d,w
}')
CPU_USED=$(awk "BEGIN{printf \"%.1f\",100-${CPU_IDLE:-0}}")

CPU metrics parsed from top output. This is more reliable than parsing /proc/stat directly.

# Memory
MEM_TOTAL=$(free -m | awk '/Mem:/{print $2}')
MEM_USED=$(free -m | awk '/Mem:/{print $3}')
MEM_AVAIL=$(free -m | awk '/Mem:/{print $7}')
MEM_PCT=$(awk "BEGIN{printf \"%.1f\",$MEM_USED*100/$MEM_TOTAL}")

Memory stats from free. Uses available memory (not just free) for more accurate reporting.

# Disk
DISK_USED_PCT=$(df / | tail -1 | awk '{gsub(/%/,"",$5); print $5}')
DISK_AVAIL=$(df -h / | tail -1 | awk '{print $4}')

# Inodes
INODE_USED_PCT=$(df -i / | tail -1 | awk '{gsub(/%/,"",$5); print $5}')

Disk and inode usage. Inodes are often overlooked — you can run out of inodes before running out of disk space.

# Network
NET_RX=$(ip -s link show ens3 2>/dev/null | awk '/RX:/{getline; print $1}' || echo "0")
NET_TX=$(ip -s link show ens3 2>/dev/null | awk '/TX:/{getline; print $1}' || echo "0")
NET_ERRORS=$(ip -s link show ens3 2>/dev/null | awk '/RX:/{getline; print $3}' || echo "0")
NET_DROPS=$(ip -s link show ens3 2>/dev/null | awk '/RX:/{getline; print $5}' || echo "0")

Network stats from ip. Note the interface is hardcoded to ens3 — change this to match your network interface (ip link show to find yours).

# Docker
if command -v docker &>/dev/null; then
    DOCKER_RUNNING=$(docker ps 2>/dev/null | tail -n +2 | wc -l)
    DOCKER_TOTAL=$(docker ps -a 2>/dev/null | tail -n +2 | wc -l)
    DOCKER_HEALTHY=$(docker ps 2>/dev/null | grep -c 'healthy' || true)
    DOCKER_UNHEALTHY=$(docker ps 2>/dev/null | grep -c 'unhealthy' || true)
    DOCKER_STOPPED=$(docker ps -a 2>/dev/null | grep -c 'Exited' || true)
    DOCKER_IMAGES=$(docker images -q 2>/dev/null | wc -l)
    DOCKER_VOLUMES=$(docker volume ls -q 2>/dev/null | wc -l)
else
    DOCKER_RUNNING="N/A"; DOCKER_TOTAL="N/A"; DOCKER_HEALTHY="N/A"
    DOCKER_UNHEALTHY="N/A"; DOCKER_STOPPED="N/A"; DOCKER_IMAGES="N/A"
    DOCKER_VOLUMES="N/A"; DOCKER_COLOR="$GRAY"
fi

Docker stats with a graceful fallback if Docker isn't installed. Tracks healthy/unhealthy containers — this is the first thing I check when something feels slow.

# Systemd
FAILED_SVC_COUNT=$(systemctl --failed --no-legend 2>/dev/null | wc -l)
FAILED_SVC_LIST=$(systemctl --failed --no-legend 2>/dev/null | awk '{print $1}' | tr '\n' ', ' | sed 's/,$//')

# Processes
PROCS_TOTAL=$(ps aux --no-heading 2>/dev/null | wc -l)
ZOMBIES=$(ps aux --no-heading 2>/dev/null | awk '$8=="Z"' | wc -l)

Systemd failures and zombie processes. Both are silent killers — services fail silently, zombies accumulate silently.

# Top processes
TOP_CPU=$(ps aux --sort=-%cpu --no-heading 2>/dev/null | head -5 | while read user pid cpu mem vsz rss tty stat start time cmd; do
    printf "<tr><td style='padding:4px 8px;border-bottom:1px solid #eee;font-size:12px;'>%s</td><td style='padding:4px 8px;border-bottom:1px solid #eee;font-size:12px;'>%s%%</td><td style='padding:4px 8px;border-bottom:1px solid #eee;font-size:12px;'>%s%%</td></tr>\n" "$cmd" "$cpu" "$mem"
done)

Top 5 CPU and memory consumers. Helps identify runaway processes without logging into the server.

# Security
SSH_FAILED=$(journalctl -u ssh --since "24 hours ago" --no-pager 2>/dev/null | grep -c "Failed" || true)
ERRORS_1H=$(journalctl -p err --since "1 hour ago" --no-pager 2>/dev/null | wc -l)
ERRORS_24H=$(journalctl -p err --since "24 hours ago" --no-pager 2>/dev/null | wc -l)
OOM_KILLS=$(dmesg 2>/dev/null | grep -c "Out of memory" || true)

Security metrics. SSH brute force detection is critical for VPS — I've seen scripts hammering 24/7.

HTML Email Builder

HTML=""
HTML+="<table>"
HTML+=$(section "SYSTEM")
HTML+=$(row "Hostname" "$MY_HOSTNAME")
HTML+=$(row "OS" "$OS")
HTML+=$(row "Kernel" "$KERNEL")
HTML+=$(row "Uptime" "$UPTIME_STR")
HTML+=$(row "Load (1/5/15m)" "$LOAD_STR" "$LOAD_COLOR")
HTML+=$(row "CPU Cores" "$CORES")
HTML+="</table>"

Builds the email section by section. Each section is a separate <table> for clean visual separation. The full script has sections for System, CPU, Memory, Disk, Network, Docker, Systemd, Processes, Top Consumers, and Security.

Email Delivery

# --- Assemble Full Email ---
EMAIL_SUBJECT="[Health Check] ${MY_HOSTNAME} - $(date '+%Y-%m-%d %H:%M')"

FULL_HTML="<!DOCTYPE html>
<html>
<head>
<meta charset=\"UTF-8\">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f6fa; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; }
.header { background: linear-gradient(135deg, #2c3e50, #3498db); color: white; padding: 20px; text-align: center; }
.header h1 { margin: 0; font-size: 20px; }
.header p { margin: 5px 0 0; opacity: 0.8; font-size: 13px; }
.content { padding: 15px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.footer { text-align: center; padding: 15px; background: #f8f9fa; color: #7f8c8d; font-size: 11px; }
</style>
</head>
<body>
<div class=\"container\">
<div class=\"header\">
<h1>Health Check Report</h1>
<p>${MY_HOSTNAME} | ${REPORT_DATE}</p>
</div>
<div class=\"content\">
${HTML}
</div>
<div class=\"footer\">Generated by healthcheck.sh | ${REPORT_DATE}</div>
</div>
</body>
</html>"

Wraps all the collected metrics in a clean HTML template with gradient header, card-style layout, and footer. The CSS is inline for email client compatibility.

# --- Send Email via curl SMTP ---
TMPFILE=$(mktemp /tmp/hc_email.XXXXXX)
trap "rm -f $TMPFILE" EXIT

{
    printf "From: %s\r\n" "$FROM"
    printf "To: %s\r\n" "$TO"
    printf "Subject: %s\r\n" "$EMAIL_SUBJECT"
    printf "MIME-Version: 1.0\r\n"
    printf "Content-Type: text/html; charset=UTF-8\r\n"
    printf "\r\n"
    printf "%s" "$FULL_HTML"
} > "$TMPFILE"

curl --url "smtp://${SMTP_HOST}:${SMTP_PORT}" \
    --mail-from "$FROM" \
    --mail-rcpt "$TO" \
    -T "$TMPFILE" \
    --insecure 2>/dev/null

Sends via curl SMTP — no dependencies beyond curl. Uses a temp file with cleanup trap. The --insecure flag is for self-signed certs in Docker networks.


Setup Instructions

1. Configure the Script

Edit the configuration block at the top of scripts/healthcheck.sh:

SMTP_HOST="172.19.0.9"       # Your SMTP relay IP
SMTP_PORT="587"              # SMTP port
FROM="[email protected]" # Sender address
TO="[email protected]"           # Recipient address

2. Set Up Cron

# Edit crontab
crontab -e

# Every 6 hours (recommended for most setups)
0 */6 * * * /home/local-machine/monitoring/scripts/healthcheck.sh

# Daily at 8am
0 8 * * * /home/debian/gitlab_repo/monitoring/scripts/healthcheck.sh

3. Test the Script

# Run manually first
/home/local-machine/monitoring/scripts/healthcheck.sh

# Check output
echo "Check your inbox for the health check email"

4. Customize Thresholds

Edit the pct_color and pct_status calls to change warning/critical levels:

# Current CPU thresholds (line 66-67)
CPU_COLOR=$(pct_color "$CPU_USED" "70" "90")    # 70% warn, 90% critical
CPU_STATUS=$(pct_status "$CPU_USED" "70" "90")

# Change to more aggressive thresholds
CPU_COLOR=$(pct_color "$CPU_USED" "50" "70")    # 50% warn, 70% critical
CPU_STATUS=$(pct_status "$CPU_USED" "50" "70")

5. Add Network Interface

Change ens3 to your actual interface:

# Find your interface
ip link show

# Update the script (lines 98-103)
NET_RX=$(ip -s link show YOUR_INTERFACE 2>/dev/null | awk '/RX:/{getline; print $1}' || echo "0")

Complete Script

Here's the full healthcheck.sh:

#!/bin/bash
# Health Check Monitoring Script
# Sends HTML email report via smtp-relay:587

set -euo pipefail

# --- Configuration ---
SMTP_HOST="172.19.0.9"
SMTP_PORT="587"
FROM="[email protected]"
TO="[email protected]"
MY_HOSTNAME=$(hostname -f)
REPORT_DATE=$(date '+%Y-%m-%d %H:%M:%S %Z')

# --- Color Codes ---
GREEN="#2ecc71"
YELLOW="#f39c12"
RED="#e74c3c"
GRAY="#95a5a6"

# --- Helper Functions ---
pct_color() {
    local val=$1 warn=$2 crit=$3
    awk "BEGIN{v=$val; w=$warn; c=$crit; if(v>c) print \"$RED\"; else if(v>w) print \"$YELLOW\"; else print \"$GREEN\"}"
}

pct_status() {
    local val=$1 warn=$2 crit=$3
    awk "BEGIN{v=$val; w=$warn; c=$crit; if(v>c) print \"CRITICAL\"; else if(v>w) print \"WARNING\"; else print \"OK\"}"
}

bytes_human() {
    local b=$1
    awk "BEGIN{b=$b; if(b>1073741824) printf \"%.2f GB\",b/1073741824; else if(b>1048576) printf \"%.2f MB\",b/1048576; else if(b>1024) printf \"%.2f KB\",b/1024; else print b\" B\"}"
}

row() {
    echo "<tr><td style='padding:8px;border-bottom:1px solid #ddd;'>$1</td><td style='padding:8px;border-bottom:1px solid #ddd;color:${3:-$GREEN};font-weight:bold;'>$2</td></tr>"
}

section() {
    echo "<tr><td colspan='2' style='padding:12px 8px 8px;background:#34495e;color:white;font-weight:bold;font-size:14px;border-radius:4px 4px 0 0;'>$1</td></tr>"
}

# --- Collect Metrics ---

# System
UPTIME_STR=$(uptime -p 2>/dev/null || uptime)
LOAD_STR=$(cat /proc/loadavg | awk '{print $1" / "$2" / "$3}')
CORES=$(nproc)
KERNEL=$(uname -r)
OS=$(grep PRETTY_NAME /etc/os-release | cut -d'"' -f2)

# CPU
read CPU_USER CPU_SYS CPU_IDLE CPU_IOW <<< $(top -bn1 | grep "%Cpu" | sed 's/%Cpu(s)://' | awk '{
    for(i=1;i<=NF;i++){
        gsub(/,/,"",$i)
        if($i=="us")u=$(i-1)
        if($i=="sy")s=$(i-1)
        if($i=="id")d=$(i-1)
        if($i=="wa")w=$(i-1)
    }
    printf "%s %s %s %s",u,s,d,w
}')
CPU_USED=$(awk "BEGIN{printf \"%.1f\",100-${CPU_IDLE:-0}}")
CPU_COLOR=$(pct_color "$CPU_USED" "70" "90")
CPU_STATUS=$(pct_status "$CPU_USED" "70" "90")

# Load
LOAD_1=$(echo "$LOAD_STR" | awk -F'/' '{gsub(/ /,"",$1); print $1}')
LOAD_COLOR=$(pct_color "$LOAD_1" "4" "5")

# Memory
MEM_TOTAL=$(free -m | awk '/Mem:/{print $2}')
MEM_USED=$(free -m | awk '/Mem:/{print $3}')
MEM_AVAIL=$(free -m | awk '/Mem:/{print $7}')
MEM_PCT=$(awk "BEGIN{printf \"%.1f\",$MEM_USED*100/$MEM_TOTAL}")
MEM_COLOR=$(pct_color "$MEM_PCT" "75" "90")
MEM_STATUS=$(pct_status "$MEM_PCT" "75" "90")

# Swap
SWAP_TOTAL=$(free -m | awk '/Swap:/{print $2}')
SWAP_USED=$(free -m | awk '/Swap:/{print $3}')
SWAP_PCT=$(awk "BEGIN{if($SWAP_TOTAL>0) printf \"%.1f\",$SWAP_USED*100/$SWAP_TOTAL; else print 0}")
SWAP_COLOR=$(pct_color "$SWAP_PCT" "50" "75")

# Disk
DISK_USED_PCT=$(df / | tail -1 | awk '{gsub(/%/,"",$5); print $5}')
DISK_AVAIL=$(df -h / | tail -1 | awk '{print $4}')
DISK_COLOR=$(pct_color "$DISK_USED_PCT" "75" "90")
DISK_STATUS=$(pct_status "$DISK_USED_PCT" "75" "90")

# Inodes
INODE_USED_PCT=$(df -i / | tail -1 | awk '{gsub(/%/,"",$5); print $5}')
INODE_COLOR=$(pct_color "$INODE_USED_PCT" "75" "90")

# Network
NET_RX=$(ip -s link show ens3 2>/dev/null | awk '/RX:/{getline; print $1}' || echo "0")
NET_TX=$(ip -s link show ens3 2>/dev/null | awk '/TX:/{getline; print $1}' || echo "0")
NET_RX_H=$(bytes_human "$NET_RX")
NET_TX_H=$(bytes_human "$NET_TX")
NET_ERRORS=$(ip -s link show ens3 2>/dev/null | awk '/RX:/{getline; print $3}' || echo "0")
NET_DROPS=$(ip -s link show ens3 2>/dev/null | awk '/RX:/{getline; print $5}' || echo "0")
NET_COLOR=$( [ "$NET_ERRORS" = "0" ] && [ "$NET_DROPS" = "0" ] && echo "$GREEN" || echo "$RED" )

# Open ports
OPEN_PORTS=$(ss -tlnp 2>/dev/null | grep -v '127.0.0.1' | grep -v '::1' | awk '{print $4}' | sed 's/.*://' | sort -un | tr '\n' ', ' | sed 's/,$//')
OPEN_PORTS_COUNT=$(echo "$OPEN_PORTS" | awk -F',' '{print NF}')

# Docker
if command -v docker &>/dev/null; then
    DOCKER_RUNNING=$(docker ps 2>/dev/null | tail -n +2 | wc -l)
    DOCKER_TOTAL=$(docker ps -a 2>/dev/null | tail -n +2 | wc -l)
    DOCKER_HEALTHY=$(docker ps 2>/dev/null | grep -c 'healthy' || true)
    DOCKER_UNHEALTHY=$(docker ps 2>/dev/null | grep -c 'unhealthy' || true)
    DOCKER_STOPPED=$(docker ps -a 2>/dev/null | grep -c 'Exited' || true)
    DOCKER_IMAGES=$(docker images -q 2>/dev/null | wc -l)
    DOCKER_VOLUMES=$(docker volume ls -q 2>/dev/null | wc -l)
    DOCKER_COLOR=$( [ "$DOCKER_UNHEALTHY" = "0" ] && [ "$DOCKER_STOPPED" = "0" ] && echo "$GREEN" || echo "$RED" )
else
    DOCKER_RUNNING="N/A"; DOCKER_TOTAL="N/A"; DOCKER_HEALTHY="N/A"
    DOCKER_UNHEALTHY="N/A"; DOCKER_STOPPED="N/A"; DOCKER_IMAGES="N/A"
    DOCKER_VOLUMES="N/A"; DOCKER_COLOR="$GRAY"
fi

# Systemd
FAILED_SVC_COUNT=$(systemctl --failed --no-legend 2>/dev/null | wc -l)
FAILED_SVC_LIST=$(systemctl --failed --no-legend 2>/dev/null | awk '{print $1}' | tr '\n' ', ' | sed 's/,$//')
SVC_COLOR=$( [ "$FAILED_SVC_COUNT" = "0" ] && echo "$GREEN" || echo "$RED" )

# Processes
PROCS_TOTAL=$(ps aux --no-heading 2>/dev/null | wc -l)
ZOMBIES=$(ps aux --no-heading 2>/dev/null | awk '$8=="Z"' | wc -l)
ZOMBIE_COLOR=$( [ "$ZOMBIES" = "0" ] && echo "$GREEN" || echo "$RED" )

# Top processes
TOP_CPU=$(ps aux --sort=-%cpu --no-heading 2>/dev/null | head -5 | while read user pid cpu mem vsz rss tty stat start time cmd; do
    printf "<tr><td style='padding:4px 8px;border-bottom:1px solid #eee;font-size:12px;'>%s</td><td style='padding:4px 8px;border-bottom:1px solid #eee;font-size:12px;'>%s%%</td><td style='padding:4px 8px;border-bottom:1px solid #eee;font-size:12px;'>%s%%</td></tr>\n" "$cmd" "$cpu" "$mem"
done)

TOP_MEM=$(ps aux --sort=-%mem --no-heading 2>/dev/null | head -5 | while read user pid cpu mem vsz rss tty stat start time cmd; do
    rss_mb=$((rss / 1024))
    printf "<tr><td style='padding:4px 8px;border-bottom:1px solid #eee;font-size:12px;'>%s</td><td style='padding:4px 8px;border-bottom:1px solid #eee;font-size:12px;'>%s%%</td><td style='padding:4px 8px;border-bottom:1px solid #eee;font-size:12px;'>%sMB</td></tr>\n" "$cmd" "$mem" "$rss_mb"
done)

# Security
SSH_FAILED=$(journalctl -u ssh --since "24 hours ago" --no-pager 2>/dev/null | grep -c "Failed" || true)
SSH_COLOR=$(pct_color "$SSH_FAILED" "10" "50")

# Logs
ERRORS_1H=$(journalctl -p err --since "1 hour ago" --no-pager 2>/dev/null | wc -l)
ERRORS_24H=$(journalctl -p err --since "24 hours ago" --no-pager 2>/dev/null | wc -l)
OOM_KILLS=$(dmesg 2>/dev/null | grep -c "Out of memory" || true)
OOM_COLOR=$( [ "$OOM_KILLS" = "0" ] && echo "$GREEN" || echo "$RED" )

# --- Build HTML ---
HTML=""
HTML+="<table>"
HTML+=$(section "SYSTEM")
HTML+=$(row "Hostname" "$MY_HOSTNAME")
HTML+=$(row "OS" "$OS")
HTML+=$(row "Kernel" "$KERNEL")
HTML+=$(row "Uptime" "$UPTIME_STR")
HTML+=$(row "Load (1/5/15m)" "$LOAD_STR" "$LOAD_COLOR")
HTML+=$(row "CPU Cores" "$CORES")
HTML+="</table>"

HTML+="<table>"
HTML+=$(section "CPU")
HTML+=$(row "Usage" "${CPU_USED}%" "$CPU_COLOR")
HTML+=$(row "User / System" "${CPU_USER:-0}% / ${CPU_SYS:-0}%")
HTML+=$(row "I/O Wait" "${CPU_IOW:-0}%")
HTML+=$(row "Status" "$CPU_STATUS" "$CPU_COLOR")
HTML+="</table>"

HTML+="<table>"
HTML+=$(section "MEMORY")
HTML+=$(row "Total / Used / Avail" "${MEM_TOTAL}MB / ${MEM_USED}MB / ${MEM_AVAIL}MB")
HTML+=$(row "Usage" "${MEM_PCT}%" "$MEM_COLOR")
HTML+=$(row "Swap" "${SWAP_USED}MB / ${SWAP_TOTAL}MB (${SWAP_PCT}%)" "$SWAP_COLOR")
HTML+="</table>"

HTML+="<table>"
HTML+=$(section "DISK")
HTML+=$(row "/" "${DISK_USED_PCT}% used, ${DISK_AVAIL} free" "$DISK_COLOR")
HTML+=$(row "Inodes" "${INODE_USED_PCT}%" "$INODE_COLOR")
HTML+="</table>"

HTML+="<table>"
HTML+=$(section "NETWORK (ens3)")
HTML+=$(row "Received" "$NET_RX_H")
HTML+=$(row "Transmitted" "$NET_TX_H")
HTML+=$(row "Errors / Drops" "${NET_ERRORS} / ${NET_DROPS}" "$NET_COLOR")
HTML+=$(row "External Ports" "${OPEN_PORTS_COUNT} (${OPEN_PORTS})")
HTML+="</table>"

HTML+="<table>"
HTML+=$(section "DOCKER")
HTML+=$(row "Containers" "${DOCKER_RUNNING}/${DOCKER_TOTAL} running")
HTML+=$(row "Healthy / Unhealthy" "${DOCKER_HEALTHY} / ${DOCKER_UNHEALTHY}" "$DOCKER_COLOR")
HTML+=$(row "Stopped" "$DOCKER_STOPPED")
HTML+=$(row "Images / Volumes" "${DOCKER_IMAGES} / ${DOCKER_VOLUMES}")
HTML+="</table>"

HTML+="<table>"
HTML+=$(section "SYSTEMD SERVICES")
HTML+=$(row "Failed Units" "$FAILED_SVC_COUNT" "$SVC_COLOR")
if [ "$FAILED_SVC_COUNT" -gt 0 ]; then
    HTML+=$(row "Details" "$FAILED_SVC_LIST" "$RED")
fi
HTML+="</table>"

HTML+="<table>"
HTML+=$(section "PROCESSES")
HTML+=$(row "Total" "$PROCS_TOTAL")
HTML+=$(row "Zombies" "$ZOMBIES" "$ZOMBIE_COLOR")
HTML+="</table>"

HTML+="<table>"
HTML+="<tr><td colspan='3' style='padding:8px;background:#ecf0f1;font-weight:bold;font-size:12px;'>Top 5 CPU</td></tr>"
HTML+="<tr style='background:#f8f9fa;'><td style='padding:4px 8px;font-size:11px;font-weight:bold;'>Process</td><td style='padding:4px 8px;font-size:11px;font-weight:bold;'>CPU%</td><td style='padding:4px 8px;font-size:11px;font-weight:bold;'>MEM%</td></tr>"
HTML+="$TOP_CPU"
HTML+="</table>"

HTML+="<table>"
HTML+="<tr><td colspan='3' style='padding:8px;background:#ecf0f1;font-weight:bold;font-size:12px;'>Top 5 Memory</td></tr>"
HTML+="<tr style='background:#f8f9fa;'><td style='padding:4px 8px;font-size:11px;font-weight:bold;'>Process</td><td style='padding:4px 8px;font-size:11px;font-weight:bold;'>MEM%</td><td style='padding:4px 8px;font-size:11px;font-weight:bold;'>RSS</td></tr>"
HTML+="$TOP_MEM"
HTML+="</table>"

HTML+="<table>"
HTML+=$(section "SECURITY")
HTML+=$(row "SSH Failed (24h)" "$SSH_FAILED" "$SSH_COLOR")
HTML+=$(row "OOM Kills" "$OOM_KILLS" "$OOM_COLOR")
HTML+=$(row "Journal Errors (1h)" "$ERRORS_1H")
HTML+=$(row "Journal Errors (24h)" "$ERRORS_24H")
HTML+="</table>"

# --- Assemble Full Email ---
EMAIL_SUBJECT="[Health Check] ${MY_HOSTNAME} - $(date '+%Y-%m-%d %H:%M')"

FULL_HTML="<!DOCTYPE html>
<html>
<head>
<meta charset=\"UTF-8\">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f6fa; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; }
.header { background: linear-gradient(135deg, #2c3e50, #3498db); color: white; padding: 20px; text-align: center; }
.header h1 { margin: 0; font-size: 20px; }
.header p { margin: 5px 0 0; opacity: 0.8; font-size: 13px; }
.content { padding: 15px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.footer { text-align: center; padding: 15px; background: #f8f9fa; color: #7f8c8d; font-size: 11px; }
</style>
</head>
<body>
<div class=\"container\">
<div class=\"header\">
<h1>Health Check Report</h1>
<p>${MY_HOSTNAME} | ${REPORT_DATE}</p>
</div>
<div class=\"content\">
${HTML}
</div>
<div class=\"footer\">Generated by healthcheck.sh | ${REPORT_DATE}</div>
</div>
</body>
</html>"

# --- Send Email via curl SMTP ---
TMPFILE=$(mktemp /tmp/hc_email.XXXXXX)
trap "rm -f $TMPFILE" EXIT

{
    printf "From: %s\r\n" "$FROM"
    printf "To: %s\r\n" "$TO"
    printf "Subject: %s\r\n" "$EMAIL_SUBJECT"
    printf "MIME-Version: 1.0\r\n"
    printf "Content-Type: text/html; charset=UTF-8\r\n"
    printf "\r\n"
    printf "%s" "$FULL_HTML"
} > "$TMPFILE"

curl --url "smtp://${SMTP_HOST}:${SMTP_PORT}" \
    --mail-from "$FROM" \
    --mail-rcpt "$TO" \
    -T "$TMPFILE" \
    --insecure 2>/dev/null

echo "[$(date)] Health check email sent to ${TO}"

Here's what actually lands in the inbox:


Final Thoughts

This script isn't a replacement for Prometheus/Grafana — it's a complement. Use it for:

  • Quick daily checks — glance at your inbox, know your server is alive
  • Fallback monitoring — when Grafana is down, this still works
  • Non-technical stakeholders — share reports with people who won't log into dashboards
  • Audit trail — keep email history for compliance or debugging

The beauty of bash scripts is their simplicity. No agents to install, no databases to maintain, no dashboards to configure. Just a script, cron, and SMTP.