← Back to Blog
DevOps & Systems

Writing a Custom Bash Script to Monitor Server Uptime and Send Discord/Slack Webhook Alerts

By JustinPublished October 3, 2026Updated October 24, 2026
Writing a Custom Bash Script to Monitor Server Uptime and Send Discord/Slack Webhook Alerts

One of my four sites went down for about twenty minutes before I noticed. Not because of anything dramatic, just a process that crashed and didn't come back on its own. Twenty minutes of silent downtime on a site I didn't happen to be actively checking is exactly the gap a monitoring script closes. I didn't want to pay for a dedicated uptime service for a handful of low-traffic personal sites, so I wrote one: a bash script, a cron job, and a webhook into Discord and Slack, which covers the actual failure mode I care about for close to nothing.

The version in this article is the second iteration. The first one worked, but it had gaps I only found by testing failures on purpose: overlapping runs, hand-built JSON that breaks on a stray quote, and no answer at all to the question "who monitors the monitor?" I've fixed all three, and I ran the final script against a local test server to confirm it sends exactly one alert when a site goes down and one when it recovers.

What This Script Actually Does, and What It Doesn't

Before the implementation, the honest limitation: this script runs on the same server it's monitoring. If the entire droplet goes down, not just one app crashing but the whole machine, the monitoring script goes down with it and sends nothing. That's a real gap, and it's the specific thing a proper external uptime service (checking from a different network entirely) catches that a self-hosted script structurally cannot. What this script does cover well is the much more common case: one specific app on the server crashing, hanging, or returning errors while the rest of the server keeps running fine, which is exactly what happened to me.

There's a cheap way to close most of that gap, covered in the heartbeat section below.

Self-hosted Bash uptime monitor detecting an application failure while showing why it cannot alert when the entire VPS goes offline

The Core Check

The script loops over a list of domains and checks each one with curl, using its exit code and timeout handling rather than trying to parse output:

bash
#!/bin/bash

SITES=(
  "https://example-a.com"
  "https://example-b.com"
  "https://example-c.com"
)

check_site() {
  local url=$1
  local status
  status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url")
  echo "$status"
}

for site in "${SITES[@]}"; do
  code=$(check_site "$site")
  echo "$site -> $code"
done

-o /dev/null discards the response body since only the status matters here, -w "%{http_code}" prints just the numeric status code, and --max-time 10 ensures a hung connection doesn't leave the whole check stuck indefinitely. A site that's timing out should count as down, not stall the script forever.

One detail I got wrong in my first draft: when a connection fails outright or times out, curl doesn't print a blank. It prints 000. That's convenient, because it means every outcome is a number you can compare. Any code in the 2xx or 3xx range counts as healthy; anything else, including 000, counts as down. In the final script I also add -L so curl follows redirects and judges the page you actually end up on, rather than the redirect itself.

Check Something Smarter Than the Homepage

A homepage returning 200 doesn't prove the app works. A Next.js page can render from cache while the database connection is dead. A dedicated health endpoint fixes that by returning 200 only when its dependencies respond:

ts
// app/api/health/route.ts
import { NextResponse } from 'next/server';
import mongoose from 'mongoose';
import { dbConnect } from '@/lib/db';

export const dynamic = 'force-dynamic'; // never cache a health check

export async function GET() {
  try {
    await dbConnect();
    const db = mongoose.connection.db;
    if (!db) throw new Error('no database handle');
    await db.admin().ping();
    return NextResponse.json({ status: 'ok' });
  } catch {
    return NextResponse.json({ status: 'error' }, { status: 503 });
  }
}

Point the monitor at /api/health instead of /. When MongoDB dies or the connection pool is exhausted, this returns 503 and the monitor alerts, even though the homepage might still look fine. I use this approach for the same reason I run an index audit on the database: failures that don't look like failures from the outside are the dangerous ones.

Sending the Actual Alert

Both Discord and Slack accept a simple POST with a JSON body to a webhook URL, with no SDK and no auth flow beyond the URL itself staying private. Discord's webhook format expects a content field:

bash
send_discord_alert() {
  local message=$1
  curl -s -X POST "$DISCORD_WEBHOOK_URL" \
    -H "Content-Type: application/json" \
    -d "{\"content\": \"$message\"}"
}

Slack's incoming webhook format is nearly identical, using text instead of content:

bash
send_slack_alert() {
  local message=$1
  curl -s -X POST "$SLACK_WEBHOOK_URL" \
    -H "Content-Type: application/json" \
    -d "{\"text\": \"$message\"}"
}

Bash uptime monitoring workflow using curl HTTP checks and Discord and Slack webhook notifications

Both webhook URLs should live in a separate file that's never committed to a repository, because anyone with the raw URL can post messages into that channel. The final script reads them from /etc/uptime-monitor.env, which you lock down with chmod 600.

The snippets above build JSON by pasting the message into a string. That works until a message contains a double quote or backslash, at which point the JSON is invalid and the alert silently never arrives. The final script escapes those two characters before sending, which is enough for the plain-text messages it generates.

Avoiding Alert Spam on a Single Blip

The naive version alerts the instant one check fails, which means a single slow response or a momentary network blip fires off a false alarm. Worse, without any state between runs, a site that's genuinely down gets a fresh duplicate alert on every single cron run, which trains you to start ignoring the channel entirely. Two fixes, both necessary:

Require consecutive failures before alerting, using a small state file per site to track how many checks in a row have failed. The alert fires exactly once, when the counter reaches the threshold, and a matching recovery message fires once when the site comes back:

bash
STATE_DIR="/var/tmp/uptime-monitor"
mkdir -p "$STATE_DIR"

for site in "${SITES[@]}"; do
  code=$(check_site "$site")
  state_file="$STATE_DIR/$(echo "$site" | md5sum | cut -d' ' -f1)"
  fail_count=$(cat "$state_file" 2>/dev/null || echo 0)

  if [[ "$code" -ge 200 && "$code" -lt 400 ]]; then
    if [[ "$fail_count" -ge 3 ]]; then
      send_discord_alert "✅ RECOVERED: $site is back up (HTTP $code)"
      send_slack_alert "✅ RECOVERED: $site is back up (HTTP $code)"
    fi
    echo 0 > "$state_file"
  else
    fail_count=$((fail_count + 1))
    echo "$fail_count" > "$state_file"
    if [[ "$fail_count" -eq 3 ]]; then
      send_discord_alert "🔴 DOWN: $site returned HTTP $code (3 consecutive failures)"
      send_slack_alert "🔴 DOWN: $site returned HTTP $code (3 consecutive failures)"
    fi
  fi
done

Bash uptime monitor using consecutive failure counts and state files to send one outage alert and one recovery alert

Requiring three consecutive failures (rather than alerting on the first one) absorbs a single transient blip without absorbing a genuine outage. Three failed checks in a row at a two-minute interval means the site has been unhealthy for around four to six minutes, which is a real problem, not noise. The recovery message matters as much as the down alert, since otherwise you're left checking manually to find out if it ever recovered.

Who Monitors the Monitor? A Heartbeat

This is the fix for the limitation at the top. The idea is a dead man's switch: at the end of every successful run, the script pings an external URL. If those pings stop arriving, that external service alerts you. It catches the cases the script can't report itself: the whole droplet is down, cron stopped, or the script crashed before finishing.

Healthchecks.io is built for exactly this: you create a check with an expected schedule, it gives you a ping URL, and it emails or messages you when a ping is late. Any similar service works the same way. The script just needs one line at the end:

bash
curl -fsS -m 10 --retry 3 "$HEARTBEAT_URL" > /dev/null

Because the check lives on someone else's infrastructure, it covers the "entire droplet is down" case that a purely self-hosted script structurally can't.

Don't Let Runs Overlap

If a run takes longer than the cron interval (several slow sites, each waiting out a 10-second timeout), the next run starts while the previous one is still going, and both read and write the same state files. The fix is a lock. flock is part of util-linux and available on essentially every Linux server:

bash
exec 9>"$STATE_DIR/.lock"
flock -n 9 || exit 0   # another run is still in progress, skip this one

The Complete Script

Here's everything together. Save it as /usr/local/bin/uptime-monitor.sh:

bash
#!/usr/bin/env bash
# uptime-monitor.sh - checks sites, alerts Discord/Slack after N consecutive failures,
# sends one recovery alert, and pings an external heartbeat so you learn if the monitor itself dies.
set -u

ENV_FILE="${ENV_FILE:-/etc/uptime-monitor.env}"   # holds DISCORD_WEBHOOK_URL, SLACK_WEBHOOK_URL, HEARTBEAT_URL
[ -f "$ENV_FILE" ] && . "$ENV_FILE"

SITES=(
  "https://example-a.com/api/health"
  "https://example-b.com"
  "https://example-c.com"
)
FAIL_THRESHOLD="${FAIL_THRESHOLD:-3}"
MAX_TIME="${MAX_TIME:-10}"
STATE_DIR="${STATE_DIR:-/var/tmp/uptime-monitor}"
mkdir -p "$STATE_DIR"

# Never let two runs overlap
exec 9>"$STATE_DIR/.lock"
flock -n 9 || exit 0

json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }

alert() {
  local msg
  msg=$(json_escape "$1")
  if [ -n "${DISCORD_WEBHOOK_URL:-}" ]; then
    curl -s -m 10 -X POST "$DISCORD_WEBHOOK_URL" -H "Content-Type: application/json" \
      -d "{\"content\":\"$msg\"}" > /dev/null
  fi
  if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
    curl -s -m 10 -X POST "$SLACK_WEBHOOK_URL" -H "Content-Type: application/json" \
      -d "{\"text\":\"$msg\"}" > /dev/null
  fi
  return 0
}

# Prints the final HTTP status after following redirects; prints 000 if the request failed or timed out
check_site() {
  curl -s -o /dev/null -L -w "%{http_code}" --max-time "$MAX_TIME" "$1"
}

for site in "${SITES[@]}"; do
  code=$(check_site "$site")
  key=$(printf '%s' "$site" | md5sum | cut -d' ' -f1)
  state_file="$STATE_DIR/$key"
  fails=$(cat "$state_file" 2>/dev/null || echo 0)

  if [[ "$code" =~ ^[0-9]+$ ]] && (( 10#$code >= 200 && 10#$code < 400 )); then
    if (( fails >= FAIL_THRESHOLD )); then
      alert "✅ RECOVERED: $site is back up (HTTP $code)"
    fi
    fails=0
  else
    fails=$((fails + 1))
    if (( fails == FAIL_THRESHOLD )); then
      alert "🔴 DOWN: $site returned HTTP $code ($FAIL_THRESHOLD consecutive failures)"
    fi
  fi

  echo "$fails" > "$state_file"
  echo "$(date -Is) $site HTTP $code fails=$fails"
done

# Heartbeat: if these pings ever stop arriving, the external service alerts you
if [ -n "${HEARTBEAT_URL:-}" ]; then
  curl -fsS -m 10 --retry 3 "$HEARTBEAT_URL" > /dev/null
fi

Create the secrets file and lock it down:

bash
sudo tee /etc/uptime-monitor.env > /dev/null <<'EOF'
DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..."
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."
HEARTBEAT_URL="https://hc-ping.com/your-uuid"
EOF
sudo chmod 600 /etc/uptime-monitor.env
sudo chmod +x /usr/local/bin/uptime-monitor.sh

Leave out any line you don't use. An empty or missing webhook is skipped rather than causing an error.

Scheduling It

A tight interval matters here, since the whole point is catching downtime quickly rather than discovering it an hour later. Every two minutes via cron is a reasonable balance between responsiveness and not hammering sites with constant requests:

bash
*/2 * * * * /usr/local/bin/uptime-monitor.sh >> /var/log/uptime-monitor.log 2>&1

Cron running a Bash uptime monitor every two minutes with state tracking, webhook alerts, and local monitoring logs

Each run logs one line per site with a timestamp, status code and the current failure count. That gives you a plain history to check when debugging the monitor itself, useful for confirming the script is actually running on schedule, separate from whether any site it's checking is up or down. Log files grow, so give this one the same rotation treatment as your other logs; I cover the approach in configuring log rotation for PM2.

Test That It Alerts Before You Trust It

Never assume a monitor works. Test it by making something fail deliberately. Add a URL you know returns an error, such as a page that doesn't exist on one of your own sites, to the SITES array, then run the script by hand three times:

bash
/usr/local/bin/uptime-monitor.sh
/usr/local/bin/uptime-monitor.sh
/usr/local/bin/uptime-monitor.sh   # the DOWN alert should arrive here, exactly once

Run it a fourth and fifth time and confirm no further alerts arrive. Then remove the bad URL, run it once more, and confirm a single RECOVERED message. If you get a duplicate DOWN message, or nothing at all, you've found a bug before an outage did.

Extending It: Slow Responses and Certificate Expiry

The same loop handles other checks by swapping the check function. To catch a site that's technically up but slow, ask curl for the total time as well as the status:

bash
read -r code secs <<< "$(curl -s -o /dev/null -L -w '%{http_code} %{time_total}' --max-time 10 "$site")"
if awk -v t="$secs" 'BEGIN { exit !(t > 3.0) }'; then
  echo "$site is slow: ${secs}s"
fi

To get warned before a TLS certificate expires, compute the days remaining. This pairs well with automated Let's Encrypt renewals, because it tells you if renewal quietly stopped working:

bash
cert_days_left() {
  local host=$1 end
  end=$(echo | openssl s_client -servername "$host" -connect "$host:443" 2>/dev/null \
        | openssl x509 -noout -enddate | cut -d= -f2)
  [ -z "$end" ] && { echo -1; return; }
  echo $(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
}

Alert when the result drops below, say, 14. A -1 means the check itself failed, which is worth an alert too.

Frequently Asked Questions

Why not just use a free tier of an existing uptime service?

For a handful of personal sites, the free tiers of most uptime services genuinely work and are worth considering. The honest reason to build this instead is wanting full control over the check logic (exact timeout behavior, custom alert thresholds, which endpoint gets checked) and not wanting another third-party account to manage. For anything beyond a personal project, a dedicated external service is usually the better call specifically because it checks from outside your own infrastructure. The heartbeat approach in this article is a middle path: you keep your own checks and add one external dependency purely as a safety net.

Should this check more than just the homepage of each site?

Yes. Checking a specific health-check endpoint, one that confirms the database connection is alive and not just that the web server responds, catches more failure modes than a homepage check alone. A server can return a cached page or a generic error page with a 200 status in some misconfigurations, which a plain status-code check on the homepage would miss entirely.

What's the actual cost of running this?

Essentially nothing beyond a few seconds of CPU time every two minutes and a tiny amount of bandwidth. It comfortably runs alongside everything else on the same small server being monitored without any noticeable resource impact. Locking down who can log into that server matters just as much, so see securing SSH access on a public VPS if you haven't already.

What if the alert itself fails to send?

A failed webhook call is silent in this script by design, so one bad request can't take down the whole run. That's why the heartbeat and the log file are both worth having: the log shows the failure count that should have triggered an alert, and the external heartbeat tells you if the whole thing stopped. If you want stronger guarantees, add a second channel such as email through your provider's SMTP relay.

Could this be extended to check more than just HTTP status?

Yes. The same pattern extends to response time, SSL certificate expiry (both shown above), or specific content appearing on the page to confirm it's not just technically up but actually rendering correctly. Each of those is the same core loop with a different check function.

Tags:bashmonitoringDiscordSlackwebhookscronuptimeDevOps

Justin is a self-taught developer who builds and runs DeelCart himself — from the articles to the server it runs on. He manages his own Linux infrastructure and writes guides based on tools and workflows he actually uses day to day.

🎓 Find Free Udemy Courses

Browse thousands of 100% OFF coupons updated daily.

Browse Free Courses →