DevOps
How I Built a Server Health-Check & Slack Alerting Script Using Only Bash
Mide Sofek Dev.to (EN Zone)
1 views
In this tutorial, we will build a simple bash script that monitors server health and sends alerts to Slack via a webhook.
The script checks four things: disk, memory, CPU, and a running service. If any of them crosses a threshold you set, the script sends a Slack message.
Before you start
You will need:
A Working Laptop (this tutorial uses macOS & macOS commands)
A terminal with bash
A Slack account
⚠️ A note on macOS vs Linux. Some of the commands here are macOS-specific. For instance, vm_stat and top -l 1 do not exist on Linux. You would use free -m and top -bn1 instead. df -h and pgrep work on both. I will flag each one as we go.
I will be using the vim text editor in this tutorial. If you prefer another editor like VS Code, nano, or Cursor, copy the final code from the public repo and follow along.
Step 1: Create a Slack Webhook URL
Go to api.slack.com/apps and click Create New App
Choose Blank App, give the app a name (e.g. Server Health Bot), and pick the workspace you want alerts to land in (create a workspace if you haven't)
In the sidebar, open Incoming Webhooks and toggle Activate Incoming Webhooks on
Scroll down and click Add New Webhook to Workspace
Choose the channel the alerts should post to, then click Allow
Copy the webhook URL that appears. It will look like this:
https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
⚠️ Treat this URL like a password. Anyone who has it can post to your channel.
Step 2: Add the Webhook URL to .env
In your terminal, run this command:
echo "SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" > .env
💡 The > operator is called output redirection. It takes whatever echo prints and writes it to .env instead of your screen. If .env doesn't exist, it gets created. If it does exist, > overwrites it completely, use >> when you want to append instead.
Next, protect .env from being accidentally committed to git by listing it in .gitignore:
echo ".env" >> .gitignore
💡 Note the >> here. Your project may already have a .gitignore, and > would wipe it. >> appends a new line and still creates the file if it's missing.
Step 3: Write the Bash Script
We will build the script in stages, testing as we go:
Create the file with a shebang
Write the alert function, then test it
Add the disk check
Add the memory check
Add the CPU check
Add the service check
Building it this way means that when something breaks, you know exactly which piece broke.
3.1 Create the script file
Run:
vim health_check.sh
This opens the vim text editor with the filename health_check.sh.
Press i to enter INSERT mode, then type the shebang at the top of the file:
#!/bin/bash
💡 The shebang tells your system which interpreter should run the file. You have to place this at the very first line. If you place a blank line or comment above it, it stops working.
Also note: bash scripts conventionally end in .sh, but the extension is optional, and the script runs fine without it. The shebang, on the other hand, is required if you want to execute the script directly with ./health_check.sh. You can skip it when you always run bash health_check.sh, but leaving it out is a habit that will bite you later.
Once done, press esc, then type :wq to write and quit the editor.
3.2 Write the alert function
Before we touch any monitoring logic, we will set up the alerting side first: a reusable function that sends a message to Slack from the terminal.
Doing this first means every check we write afterwards has somewhere to report to, and we can confirm Slack works before adding anything that might obscure the problem.
source {THE FULL PATH TO YOUR .env file} # e.g: source /Users/mac/MyProjects/linux_lessons/ServerHealthStatusAlert/.env
send_alert(){
msg=$1;
curl -X POST -H 'Content-type: application/json' --data "{\"text\": \"$msg\"}" $SLACK_WEBHOOK_URL
}
💡 Watch your quotes in the curl payload. Single quotes in bash prevent variable expansion, so '{"text": "$MESSAGE"}' sends the literal string $MESSAGE to Slack rather than your message. You need double quotes around the parts that contain variables.
Test it before moving on:
send_alert "Bash Scripting is Cool!"
If a message shows up in your Slack channel, the hard part is done.
Your script should now look like this:
code so far
#!/bin/bash
source /Users/mac/MyProjects/Devops_Mastery/linux_lessons/ServerHealthStatusAlert/.env
send_alert(){
msg=$1;
curl -X POST -H 'Content-type: application/json' --data "{\"text\": \"$msg\"}" $SLACK_WEBHOOK_URL
}
send_alert "Bash Scripting is Cool!"
3.3 Disk check
To read disk usage, we will use df -h.
The disk check Logic
disk_usage=$(df -h | grep '/$' | awk '{ print $5 }' | sed "s/%//")
disk_threshold=80
if [ "$disk_usage" -gt $disk_threshold ]; then
send_alert "🚨 [ALERT] Disk usage high on $(hostname): $disk_usage% (threshold: $disk_threshold%) - $(date)"
fi
3.4 Memory check
To read memory usage on macOS, we will use vm_stat.
⚠️ macOS only. Linux exposes memory through /proc/meminfo (usually read via free -m). vm_stat reports memory in pages rather than megabytes, so we have to do a little arithmetic to get a usable percentage.
mem_threshold=80
pages_free=$(vm_stat | grep "Pages free" | awk '{ print $3 }' | sed "s/.$//")
pages_active=$(vm_stat | grep "Pages active" | awk '{ print $3 }' | sed "s/.$//")
pages_inactive=$(vm_stat | grep "Pages inactive" | awk '{ print $3 }' | sed "s/.$//")
pages_speculative=$(vm_stat | grep "Pages speculative" | awk '{ print $3 }' | sed "s/.$//")
pages_wired=$(vm_stat | grep "Pages wired down" | awk '{ print $4 }' | sed "s/.$//")
pages_compressed=$(vm_stat | grep "Pages occupied by compressor:" | awk '{ print $5 }' | sed "s/.$//")
free_group=$(echo "$pages_free + $pages_speculative" | bc)
used=$(echo "$pages_active + $pages_inactive + $pages_wired + $pages_compressed" | bc)
total=$(echo "$free_group + $used" | bc)
used_percent=$(echo "scale=2; $used / $total * 100" | bc)
mem_alert=$(echo "$used_percent > $mem_threshold" | bc)
if [ "$mem_alert" -eq 1 ]; then
send_alert "🚨 [ALERT] Memory usage high on $(hostname): $used_percent% (threshold: $mem_threshold%) - $(date)"
fi
💡 Bash can only compare integers. [ 78.4 -gt 80 ] will throw an error, not a wrong answer. That's why we pipe (using |) the calculation through bc, which handles decimals and returns 1 for true and 0 for false.
3.5 CPU check
To read CPU usage, we will use top -l 1.
⚠️ macOS only. The -l 1 flag means "take one sample and exit". On Linux, the equivalent is top -bn1.
top reports CPU idle percentage, not usage, so we get usage by subtracting from 100:
usage = 100 - idle
cpu_threshold=80
cpu_idle=$(top -l 1 | grep "CPU usage:" | awk '{ print $7 }' | sed 's/%//')
cpu_usage=$(echo "100 - $cpu_idle" | bc)
cpu_alert=$(echo "$cpu_usage > $cpu_threshold"| bc)
if [ $cpu_alert -eq 1 ]; then
send_alert "🚨 [ALERT] CPU usage high on $(hostname): $cpu_usage% (threshold: $cpu_threshold%) - $(date)"
fi
3.6 Service check
To check whether a service is running, we will use pgrep.
service_name="sshd"
if pgrep $service_name > /dev/null; then
echo "running"
else
echo "not running"
send_alert "🚨 [ALERT] Service $service_name is not running on $(hostname) - $(date)"
fi
⚠️ A real limitation worth knowing. pgrep sshd will come back empty on macOS even when Remote Login is enabled. macOS uses launchd socket activation — sshd isn't kept running in the background, it's started on demand when a connection actually arrives. So there's no persistent process for pgrep to find.
On a Linux server, pgrep sshd works exactly as you'd expect. To test the logic locally on a Mac, point it at a process that is always running, like Finder by setting service_name="finder".
Escape (with esc) then save the bash file with :wq
Step 4: Run the script
bash health_check.sh
You should see your checks run, and any that breach a threshold should show up in Slack.
Step 5: Schedule it with cron
Running the script by hand defeats the point. Let's have cron run it on a schedule.
Open your crontab:
crontab -e
This opens a text editor where you can write your cron schedules. Press i to enter INSERT mode if the editor doesn't do it for you.
Then add your schedule:
0 * * * * /absolute/path/to/health_check.sh
This runs the script at the top of every hour.
💡 Always use absolute paths in cron. Cron runs with a minimal environment and doesn't start in your project directory, so a relative path like ./health_check.sh fails silently. This applies to every path in your script, including your log file.
If you're unsure what a schedule does, paste it into crontab.guru — it translates cron expressions into plain English.
⚠️ MacOS Note: macOS sandboxing may block cron from reading files in protected directories. If your script runs fine by hand but does nothing on a schedule, grant Full Disk Access to cron under System Settings → Privacy & Security → Full Disk Access.
The final script
#!/bin/bash
source /Users/mac/MyProjects/Devops_Mastery/linux_lessons/ServerHealthStatusAlert/.env
send_alert(){
msg=$1;
curl -X POST -H 'Content-type: application/json' --data "{\"text\": \"$msg\"}" $SLACK_WEBHOOK_URL
}
send_alert "Bash Scripting is Cool!"
## DISK CHECK LOGIC
disk_usage=$(df -h | grep '/$' | awk '{ print $5 }' | sed "s/%//")
disk_threshold=80
if [ "$disk_usage" -gt $disk_threshold ]; then
send_alert "🚨 [ALERT] Disk usage high on $(hostname): $disk_usage% (threshold: $disk_threshold%) - $(date)"
fi
## MEMORY CHECK LOGIC
mem_threshold=80
pages_free=$(vm_stat | grep "Pages free" | awk '{ print $3 }' | sed "s/.$//")
pages_active=$(vm_stat | grep "Pages active" | awk '{ print $3 }' | sed "s/.$//")
pages_inactive=$(vm_stat | grep "Pages inactive" | awk '{ print $3 }' | sed "s/.$//")
pages_speculative=$(vm_stat | grep "Pages speculative" | awk '{ print $3 }' | sed "s/.$//")
pages_wired=$(vm_stat | grep "Pages wired down" | awk '{ print $4 }' | sed "s/.$//")
pages_compressed=$(vm_stat | grep "Pages occupied by compressor:" | awk '{ print $5 }' | sed "s/.$//")
free_group=$(echo "$pages_free + $pages_speculative" | bc)
used=$(echo "$pages_active + $pages_inactive + $pages_wired + $pages_compressed" | bc)
total=$(echo "$free_group + $used" | bc)
used_percent=$(echo "scale=2; $used / $total * 100" | bc)
mem_alert=$(echo "$used_percent > $mem_threshold" | bc)
if [ "$mem_alert" -eq 1 ]; then
send_alert "🚨 [ALERT] Memory usage high on $(hostname): $used_percent% (threshold: $mem_threshold%) - $(date)"
fi
## CPU CHECK LOGIC
cpu_threshold=80
cpu_idle=$(top -l 1 | grep "CPU usage:" | awk '{ print $7 }' | sed 's/%//')
cpu_usage=$(echo "100 - $cpu_idle" | bc)
cpu_alert=$(echo "$cpu_usage > $cpu_threshold"| bc)
if [ $cpu_alert -eq 1 ]; then
send_alert "🚨 [ALERT] CPU usage high on $(hostname): $cpu_usage% (threshold: $cpu_threshold%) - $(date)"
fi
## SERVICE CHECK LOGIC
service_name="sshd"
if pgrep $service_name > /dev/null; then
echo "running"
else
echo "not running"
send_alert "🚨 [ALERT] Service $service_name is not running on $(hostname) - $(date)"
You can also find the full code on github
Read original: https://dev.to/midesofek/how-i-built-a-server-health-check-slack-alerting-script-using-only-bash-4ne4
← Previous
FastMCP 3 4 migration: the breaking changes that compile
Next →
What a Kubernetes controller actually does when you break something
Related
What a Kubernetes controller actually does when you break something
DevOps
1
DEV Community
Introducing KDM-cli: Monitor Kubernetes & Docker from your Terminal
DevOps
2
DEV Community
I Used Docker Before I Understood It
DevOps
3
Dev.to (EN Zone)
Kubernetes and Swap: Why It's Disabled by Default (And How That's Changing) - A kubeadm Debugging Story
DevOps
2
Dev.to (EN Zone)
Comments0
No comments yet — be the first