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