How I Built a Linux System Health Dashboard from Scratch
A personal walkthrough — from reading kernel files to automated scheduling with cron and systemd
How I Built a Linux System Health Dashboard from Scratch
A personal walkthrough — from reading kernel files to automated scheduling with cron and systemd
I started this project because I wanted to actually learn Linux — not memorise a list of commands, not follow a checklist — but genuinely understand how the system works, break things and then correct them. Building something real felt like the only honest way to do that.
What I ended up with is a Bash script called dashboard.sh that monitors CPU load, RAM, disk usage, network traffic, open ports, and service availability. It logs alerts when something goes wrong, rotates the log so it never grows too large, and runs automatically on a schedule via cron and systemd. No frameworks, no external libraries. Just Linux tools and Bash.
This article is a full walkthrough of exactly what I built, what I saw at each step, the errors I hit, and what I learned from each one. If you are junior and trying to build a real Linux foundation, this is the project I would recommend.
Everything in Linux is a file. Not just documents and config files — your CPU load right now, your RAM usage right now, your network traffic right now. The kernel exposes all of it as readable text files inside /proc and /sys. There is no special API to call. You just read a file.
cat /proc/loadavg # live CPU load right now
cat /proc/meminfo # live RAM stats right now
cat /proc/net/dev # live network traffic right now
Once I understood that, the whole project made sense. My dashboard was not doing anything clever. It was just reading files the kernel was already writing, formatting the data, and deciding whether to raise an alert. That simplicity is what makes Linux so powerful.
Phase 1 — CPU, RAM, and Disk
My goal for Phase 1 was simple — a script I could run from the terminal that prints CPU load, RAM usage, and disk usage in a readable, color-coded format. No alerts yet, no scheduling. Just read the kernel data and display it.
Step 1 — The script skeleton
I created the file with:
nano ~/dashboard.sh
And started with the bare minimum — colors, a header function, and nothing else:
#!/bin/bash
# Colors
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
print_header() {
echo "=============================="
echo " System Health Dashboard"
echo " $(hostname) - $(date '+%Y-%m-%d %H:%M:%S')"
echo "=============================="
}
print_header
Then I made it executable and ran it:
chmod +x ~/dashboard.sh
./dashboard.sh
What I saw:
==============================
System Health Dashboard
Testing1 — 2026-05-01 09:00:00
==============================
Even that small output taught me three things. The #!/bin/bash shebang tells the OS which interpreter to use — without it, the system does not know your file is a Bash script. chmod +x sets the executable permission bit, which is why ./dashboard.sh works instead of having to type bash dashboard.sh every time. And $(hostname) is command substitution — the $() runs the command inside it and replaces itself with the output inline in the string.
Step 2 — CPU load
check_cpu() {
local load=$(awk '{print $1}' /proc/loadavg)
local cores=$(nproc)
echo -e "CPU Load (1m): $load | Cores: $cores"
}
What I saw:
CPU Load (1m): 0.22 | Cores: 4
The file /proc/loadavg contains five space-separated values. The first three are 1-minute, 5-minute, and 15-minute load averages. awk '{print $1}' extracts just the first field — $1 in awk means the first whitespace-separated column. This became the single most used pattern in my entire script.
local was also new to me here. In Bash, variables are global by default. local scopes them to the function so they do not leak out and accidentally overwrite something elsewhere. I started using it on every variable inside functions from this point on.
Step 3 — RAM usage
check_memory() {
local total=$(grep MemTotal /proc/meminfo | awk '{print $2}')
local available=$(grep MemAvailable /proc/meminfo | awk '{print $2}')
local used=$(( (total - available) * 100 / total ))
if [ $used -ge 80 ]; then
echo -e "${RED}RAM: ${used}% used${NC}"
elif [ $used -ge 60 ]; then
echo -e "${YELLOW}RAM: ${used}% used${NC}"
else
echo -e "${GREEN}RAM: ${used}% used${NC}"
fi
}
What I saw:
RAM: 12% used
Printed in green because 12% is well under 60%.
A few things clicked for me here. grep MemTotal /proc/meminfo finds the line containing MemTotal and prints it. Piping | feeds that line directly into awk '{print $2}' which extracts the number. The pipe is what lets small focused tools chain together — each one doing one job cleanly.
$(( )) is how Bash does integer arithmetic. I learned quickly that it only does integers — $(( 5 / 2 )) gives 2, not 2.5. For percentages that is fine.
The if / elif / else / fi block was my first real conditional in Bash. -ge means greater than or equal to. The fi closing the block is if backwards — a Bash convention that initially looked strange but became second nature fast.
Step 4 — Disk usage
check_disk() {
echo "Disk usage:"
df -h | grep '^/dev' | while read -r line; do
local mount=$(echo "$line" | awk '{print $6}')
local pct=$(echo "$line" | awk '{print $5}' | tr -d '%')
if [ "$pct" -ge 80 ]; then
echo -e " ${RED}$mount: ${pct}%${NC}"
else
echo -e " ${GREEN}$mount: ${pct}%${NC}"
fi
done
}
What I saw:
Disk usage:
/: 6%
df -h reports disk space for all mounted filesystems. The -h flag makes it human readable — it shows 20G instead of raw kilobytes. I piped it through grep '^/dev' to filter out virtual filesystems like tmpfs. The ^ anchors the pattern to the start of the line, so only lines where the filesystem path begins with /dev pass through.
The while read -r line loop reads command output one line at a time, putting each line into $line. The -r flag prevents backslashes from being interpreted as escape characters — I now use it by default whenever reading file content.
tr -d '%' strips the percent sign from values like 72% so Bash can compare them as integers. Without it, [ "72%" -ge 80 ] fails because Bash cannot treat a string with a percent sign as a number.
Step 5 — The main section
print_header
check_cpu
check_memory
check_disk
Full Phase 1 output:
==============================
System Health Dashboard
Testing1 — 2026-05-01 09:00:00
==============================
CPU Load (1m): 0.22 | Cores: 4
RAM: 12% used
Disk usage:
/: 6%
Phase 1 done. The script was reading real kernel data and displaying it cleanly.
Phase 2 — Network, Ports, and Services
Phase 2 was about making the dashboard network-aware. I wanted to see active interfaces, live traffic bytes, open listening ports, whether specific services were actually responding, and whether the machine had internet access.
Step 1 — Network interfaces and traffic
check_network() {
echo ""
echo "=============================="
echo " Network"
echo "=============================="
ip -o -4 addr show | awk '$2 != "lo" {print $2, $4}' | while read -r iface ip; do
echo " Interface: $iface IP: $ip"
done
echo ""
echo " Traffic (bytes since last boot):"
awk 'NR>2 && $1 !~ /^lo/ {
gsub(/:/, "", $1)
printf " %-10s RX: %-12s TX: %s\n", $1, $2, $10
}' /proc/net/dev
}
What I saw:
==============================
Network
==============================
Interface: enp0s3 IP: 10.0.2.15/24
Traffic (bytes since last boot):
enp0s3 RX: 54102476 TX: 478274
I hit my first real error here — a typo. The printf format string had %-12S with a capital S instead of lowercase s. awk threw a runtime error and the output broke. That error taught me more about printf format specifiers than any explanation would have. %s is a string field. %-12s is a left-aligned string in a 12-character wide column. Capital letters are not valid format specifiers in awk.
I also had a missing closing } on the function which caused the header to print in the middle of the network section. Finding that taught me to always test one function at a time rather than wiring everything together and debugging a wall of broken output.
ip -o -4 addr show lists IPv4 addresses with one line per result. I filtered the loopback interface with awk '$2 != "lo"' — a condition inside awk that skips rows where the second field is lo.
NR>2 in the traffic section skips the first two header lines of /proc/net/dev. gsub(/:/, "", $1) removes the trailing colon from interface names like eth0:. printf "%-10s" aligns the output into clean columns.
Step 2 — Open ports
check_ports() {
echo ""
echo " Listening ports:"
ss -tlnp | awk 'NR>1 {print $4, $6}' | while read -r addr process; do
echo " Port: $addr $process"
done
}
What I saw:
Listening ports:
Port: 0.0.0.0:22
Port: 0.0.0.0:80
ss is the modern replacement for netstat. The flags I used: -t for TCP only, -l for listening sockets only, -n to show port numbers instead of service names, and -p to show which process owns each socket. Running it with sudo reveals the process names alongside each port.
Step 3 — Service reachability
This was the part I found most interesting. I wanted to probe whether a service was actually responding — not just whether the port was open, but whether a connection could be made.
My first version used cut -d: -f2 to parse host and port out of strings like "Google DNS:8.8.8.8:53". It worked for IP addresses but broke on localhost because localhost itself contains no colon before the host, so cut was returning empty values. In the output I could see localhost: with nothing after it.
I fixed it by switching to parallel arrays — three arrays indexed by the same counter, no string splitting needed:
check_services() {
echo ""
echo " Service checks:"
local names=("Google DNS" "Local SSH" "HTTP")
local hosts=("8.8.8.8" "localhost" "localhost")
local ports=("53" "22" "80")
local total=${#names[@]}
for i in $(seq 0 $(( total - 1 )) ); do
local name="${names[$i]}"
local host="${hosts[$i]}"
local port="${ports[$i]}"
if timeout 2 bash -c "echo >/dev/tcp/$host/$port" 2>/dev/null; then
echo -e " ${GREEN}[UP]${NC} $name ($host:$port)"
else
echo -e " ${RED}[DOWN]${NC} $name ($host:$port)"
fi
done
}
What I saw:
Service checks:
[UP] Google DNS (8.8.8.8:53)
[UP] Local SSH (localhost:22)
[DOWN] HTTP (localhost:80)
HTTP was DOWN because I had not installed a web server yet. I installed nginx to test it:
sudo apt install nginx -y
sudo systemctl start nginx
./dashboard.sh
HTTP flipped to [UP]. I stopped nginx and it flipped back to [DOWN]. Watching that happen in real time made the whole service monitoring concept click.
The TCP probing trick is one of the best things I learned in this project. /dev/tcp/host/port is a Bash-specific feature — writing to it attempts a TCP connection with no external tools at all. timeout 2 kills the attempt after 2 seconds so a dead host cannot hang the script. 2>/dev/null discards the error output so refused connections do not pollute the display.
${#names[@]} gives the count of array elements. ${names[$i]} accesses an element by index. seq 0 2 generates the numbers 0 1 2 for the loop to iterate over.
Step 4 — Internet connectivity
check_internet() {
echo ""
echo " Internet connectivity:"
if curl -s --max-time 3 https://1.1.1.1 > /dev/null 2>&1; then
echo -e " ${GREEN}[UP]${NC} Internet reachable"
else
echo -e " ${RED}[DOWN]${NC} Internet unreachable"
fi
}
What I saw:
Internet connectivity:
[UP] Internet reachable
curl -s suppresses the progress output. --max-time 3 sets a hard 3-second limit on the whole request. > /dev/null discards the response body because I only care about the exit code — did the connection succeed or not. if curl ... works because if in Bash tests exit codes directly. 0 means success, anything else means failure.
Phase 2 full output
=================================
System Health Dashboard
Testing1 - 2026-05-01 13:05:01
=================================
CPU Load (1m): 0.22 | Cores: 4
RAM: 12% used
Disk usage:
/: 6%
==========================
Network
==========================
Interface: enp0s3 IP: 10.0.2.15/24
Traffic (bytes since last boot):
enp0s3 RX: 54102476 TX: 478274
Listening ports:
Port: 0.0.0.0:22
Port: 0.0.0.0:80
Service checks:
[UP] Google DNS (8.8.8.8:53)
[UP] Local SSH (localhost:22)
[UP] HTTP (localhost:80)
Internet connectivity:
[UP] Internet reachable
Phase 3 — Alerts and Logging
Up to this point the dashboard reported. Phase 3 was about making it react. When RAM crossed a threshold, a disk filled up, or a service went down, I wanted it to write a timestamped alert to a log file. I also wanted the log to rotate itself so it would not grow forever.
Step 1 — Log config
I added two lines near the top of the script just below the color variables:
LOG_FILE="$HOME/dashboard.log"
MAX_LOG_LINES=500
$HOME always resolves to the current user's home directory. Using it instead of a hardcoded path means the script works correctly regardless of which user runs it.
Step 2 — The logging function
write_log() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" >> "$LOG_FILE"
}
$1 and $2 are positional parameters — the arguments passed to a function. When I call write_log "WARN" "RAM is high", inside the function $1 is WARN and $2 is RAM is high. Bash functions receive arguments this way with no declaration needed.
>> is an append redirect. A single > would overwrite the file on every run. >> adds to the end, preserving history. That distinction matters a lot for a log file.
Step 3 — Log rotation
rotate_log() {
if [ -f "$LOG_FILE" ]; then
local lines=$(wc -l < "$LOG_FILE")
if [ "$lines" -gt "$MAX_LOG_LINES" ]; then
tail -n $MAX_LOG_LINES "$LOG_FILE" > "$LOG_FILE.tmp"
mv "$LOG_FILE.tmp" "$LOG_FILE"
write_log "INFO" "Log rotated — trimmed to $MAX_LOG_LINES lines"
fi
fi
}
-f is a file test operator — [ -f "$LOG_FILE" ] returns true only if the file exists and is a regular file. I needed this because on the very first run the log file does not exist yet, and trying to count its lines would crash the function.
wc -l < "$LOG_FILE" counts the lines. I used < instead of passing the filename as an argument because wc -l filename prints the count and the filename, while wc -l < filename prints only the count — which is what I needed for the comparison.
tail -n 500 keeps only the most recent 500 lines. mv replaces the original file with the trimmed version. On Linux mv is atomic — it either fully completes or does not happen, so there is no risk of losing the log mid-operation.
Step 4 — Adding alerts to existing functions
I went back into check_memory, check_disk, and check_services and added write_log calls wherever a threshold was crossed or a service went down.
In check_memory:
if [ $used -ge 80 ]; then
echo -e " ${RED}RAM: ${used}% used${NC}"
write_log "WARN" "RAM usage high: ${used}%"
elif [ $used -ge 60 ]; then
echo -e " ${YELLOW}RAM: ${used}% used${NC}"
write_log "INFO" "RAM usage moderate: ${used}%"
else
echo -e " ${GREEN}RAM: ${used}% used${NC}"
fi
In check_disk:
if [ "$pct" -ge 80 ]; then
echo -e " ${RED}$mount: ${pct}%${NC}"
write_log "WARN" "Disk usage high on $mount: ${pct}%"
else
echo -e " ${GREEN}$mount: ${pct}%${NC}"
fi
In check_services:
if timeout 2 bash -c "echo >/dev/tcp/$host/$port" 2>/dev/null; then
echo -e " ${GREEN}[UP]${NC} $name ($host:$port)"
write_log "INFO" "Service UP: $name ($host:$port)"
else
echo -e " ${RED}[DOWN]${NC} $name ($host:$port)"
write_log "WARN" "Service DOWN: $name ($host:$port)"
fi
Step 5 — Summary function
print_summary() {
echo ""
echo "=============================="
echo " Summary"
echo "=============================="
local warnings=$(grep -c "\[WARN\]" "$LOG_FILE" 2>/dev/null)
warnings=${warnings:-0}
if [ "${warnings//[^0-9]/}" -gt 0 ] 2>/dev/null; then
echo -e " ${RED}Warnings in log: $warnings${NC}"
local last_warn=$(grep "\[WARN\]" "$LOG_FILE" 2>/dev/null | tail -n 1)
echo -e " Last: $last_warn"
else
echo -e " ${GREEN}All systems normal${NC}"
fi
echo ""
echo " Log file: $LOG_FILE"
}
I hit an error here too. The original version used || echo 0 as a fallback, but grep -c was sometimes returning 0\n0 across multiple lines which Bash could not compare as an integer. The fix was ${warnings:-0} — default value substitution. If warnings is empty or unset, use 0. And ${warnings//[^0-9]/} strips any non-numeric characters before the comparison so whitespace or newline characters cannot break it.
grep -c "\[WARN\]" counts matching lines instead of printing them. The backslashes are necessary because [ and ] have special meaning in regex — without escaping them, [WARN] would mean "any single character from W, A, R, N" rather than the literal string.
Step 6 — Updated main section
rotate_log
write_log "INFO" "Dashboard run started"
print_header
check_cpu
check_memory
check_disk
check_network
check_ports
check_services
check_internet
print_summary
write_log "INFO" "Dashboard run completed"
What the log file looked like after the first run:
[2026-05-01 13:05:01] [INFO] Dashboard run started
[2026-05-01 13:05:01] [INFO] Service UP: Google DNS (8.8.8.8:53)
[2026-05-01 13:05:01] [INFO] Service UP: Local SSH (localhost:22)
[2026-05-01 13:05:02] [WARN] Service DOWN: HTTP (localhost:80)
[2026-05-01 13:05:03] [INFO] Dashboard run completed
Seeing that log appear after a run felt like the biggest milestone of the project. The dashboard was not just displaying data anymore — it was keeping a persistent record.
Phase 4 — Scheduling with cron and systemd
Phase 4 was about handing the script off to the system entirely. I wanted it to run automatically on a schedule with no input from me. I started with cron because it is simpler, then set up a systemd timer to understand how the modern approach compares.
Before scheduling anything
I confirmed three things first:
# Script is executable
chmod +x ~/dashboard.sh
# Runs cleanly with its full path - cron does not know about ~/
/bin/bash /home/vboxuser/dashboard.sh
# Shebang is on line 1
head -1 ~/dashboard.sh
# Returns: #!/bin/bash
Testing with the full path before touching cron saved me a debugging session. Cron does not expand ~/ — it needs the absolute path.
cron
Understanding the syntax
A cron schedule is five time fields followed by the command:
* * * * * command
│ │ │ │ │
│ │ │ │ └── day of week (0-7, Sunday is both 0 and 7)
│ │ │ └──── month (1-12)
│ │ └────── day of month (1-31)
│ └──────── hour (0-23)
└────────── minute (0-59)
* means every. */5 means every 5. Some examples I used to learn the syntax:
*/5 * * * * # every 5 minutes
0 * * * * # every hour on the hour
0 9 * * 1 # every Monday at 9am
0 9 1 * * # first day of every month at 9am
Adding the job
crontab -e
I added this line to run every Monday at 9am:
0 9 * * 1 /bin/bash /home/vboxuser/dashboard.sh >> /home/vboxuser/dashboard.log 2>&1
2>&1 redirects stderr to wherever stdout is going — the log file. Without it, any errors from cron would disappear silently.
The environment problem
The first time my cron job did not produce any log entries it was because cron runs with a minimal environment. It does not load .bashrc and does not know the PATH I use in my terminal. Commands like awk and curl were not being found.
The fix was to set the environment at the top of the crontab:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
HOME=/home/vboxuser
Confirming it worked
# List current jobs
crontab -l
# Watch the log live
tail -f ~/dashboard.log
# See what cron actually ran
grep CRON /var/log/syslog | tail -20
When the job fired I saw a fresh timestamped dashboard run appear in the log without touching the terminal. That moment — watching the system run your script entirely on its own — is a satisfying one.
Systemd timers
After getting cron working I set up a systemd timer to do the same job. It requires two files.
The service file
sudo nano /etc/systemd/system/dashboard.service
[Unit]
Description=System Health Dashboard
[Service]
Type=oneshot
User=vboxuser
ExecStart=/bin/bash /home/vboxuser/dashboard.sh
StandardOutput=append:/home/vboxuser/dashboard.log
StandardError=append:/home/vboxuser/dashboard.log
The timer file
sudo nano /etc/systemd/system/dashboard.timer
[Unit]
Description=Run dashboard once a week
[Timer]
OnBootSec=1min
OnCalendar=Mon *-*-* 09:00:00
Persistent=true
[Install]
WantedBy=timers.target
Enabling it
sudo systemctl daemon-reload
sudo systemctl enable dashboard.timer
sudo systemctl start dashboard.timer
daemon-reload tells systemd to re-read all unit files after I created them. Always run this after making changes to service or timer files.
Confirming it is scheduled
systemctl list-timers --all
My timer appeared in the list with the exact date and time of the next run. Seeing Mon 2026-05-04 09:00:00 UTC 2 days next to my dashboard timer confirmed it was registered correctly.
Viewing the logs
journalctl -u dashboard.service -n 20
journalctl -u dashboard.service -f
journalctl is systemd's log viewer. -u filters to a specific unit. -f follows it live. This is the biggest practical difference I noticed between cron and systemd — cron runs silently and only shows you what you redirect yourself. systemd records every run, every exit code, and every error in the journal automatically.
Disabling cron once systemd was running
Running both at the same time would fire the script twice. I commented out the cron line:
crontab -e
# 0 9 * * 1 /bin/bash /home/vboxuser/dashboard.sh >> /home/vboxuser/dashboard.log 2>&1
The # makes it a comment — ignored by cron but kept for reference.
cron vs systemd — what I actually noticed
cron systemd timer Setup One line in crontab Two unit files Logging Only what you redirect Full journal automatically Missed runs Silently skipped Caught up on next boot with Persistent=true Environment Minimal, needs manual PATH Inherits systemd environment Dependencies None Can depend on network, other services Best for Quick setup, simple schedules Production servers, complex jobs
I would recommend doing both. Cron teaches you scheduling fundamentals fast. systemd teaches you how modern Linux actually manages services and timers. They complement each other.
Everything I learned from this project
Looking back across all four phases, here is every concept I touched — not as a reading list, but as things I used in working code and understood because I built something with them.
Linux file system — the directory tree starting at /, virtual kernel files in /proc and /sys, log files in /var/log, configuration in /etc, and the principle that everything is a file.
Text processing — grep for filtering lines by pattern, awk for field extraction and conditional processing, cut for delimiter-based splitting, tr for character deletion, wc for counting, sed for substitution.
Bash scripting — the shebang, functions with local variables, positional parameters $1 $2, arrays, parallel array indexing, if/elif/else/fi conditionals, while read and for loops, $(()) arithmetic expansion, $() command substitution, ${var:-default} default substitution, ANSI escape codes for color.
Pipes and redirects — | to chain commands, > to overwrite, >> to append, < to feed a file as input, 2> to redirect errors, 2>&1 to merge stderr into stdout, /dev/null to discard output.
Networking — ip for interface addresses, ss for socket state, curl for HTTP probing, /dev/tcp for raw TCP connections, timeout for safe command execution.
Logging — timestamped entries, append redirects, log rotation with tail and mv, reading logs with grep and tail, tail -f for live following.
Scheduling — cron syntax and the five time fields, crontab management, cron environment variables, systemd service and timer unit files, journalctl for journal viewing.
Exit codes — every command returns 0 for success or non-zero for failure, and if in Bash tests those codes directly.
File permissions — chmod +x to make a script executable, file test operators like -f for regular files and -d for directories.
Regex basics — anchoring with ^, escaping special characters like \[, pattern matching with !~ in awk.
Final thoughts
The reason I would recommend this project to anyone junior learning Linux is that nothing stays abstract. Every concept gets applied immediately to something you can run, observe, and break. The best learning I did came from the errors — the capital S in the printf format string, the missing closing brace that caused the header to print mid-output, the grep -c returning two lines instead of one and crashing the integer comparison. Each of those was a real debugging session that stuck with me far more than any tutorial paragraph.
If I had followed a tutorial and copied working code, I would have missed all of that.
Build it. Break it. Read the error. Fix it. That is how Linux actually gets learned.
The full source code is on my GitHub — link below.
https://github.com/cjemmyyy/System-health-dashboard.git
Built on Ubuntu 25 running in VirtualBox. Every error shown in this article is a real error I actually hit.
메타데이터
- post_id
- 0d7e80f15e5c
- slug
- how-i-built-a-linux-system-health-dashboard-from-scratch-0d7e80f15e5c
- url
- https://medium.com/@cjemmyyy_33657/how-i-built-a-linux-system-health-dashboard-from-scratch-0d7e80f15e5c
- canonical_url
- https://medium.com/@cjemmyyy_33657/how-i-built-a-linux-system-health-dashboard-from-scratch-0d7e80f15e5c
- author_url
- https://medium.com/@cjemmyyy_33657
- status
- ok
- fetched_at
- 2026-06-15 20:49:13