Bash Uptime Monitoring Script With Telegram Alerts

– min read

I found out last month that my NAS had been offline for two full days, and only because I went to fetch a file from it. Something needed to watch the LAN services, and I did not want another container for five endpoints. So I wrote 75 lines of bash instead.

Telegram Bot API: core.telegram.org/bots/api
curl: curl.se/docs


The problem with simple ping scripts

The naive version is a curl loop that sends a Telegram message on every failed check. On a five minute cron that is 288 alerts a day for one broken device, and by day two you mute the bot and the monitor is dead anyway. The script below fixes this with a single rule: alert on state change, never on state.


The target file

One line per service, name and URL, hash for comments. It follows the same file-driven pattern as my TLS certificate expiry checker. This is the exact file the demo used, two sandbox servers and one closed port. The data is synthetic, no real gear involved:

# name,url
jellyfin,http://127.0.0.1:8091/
gitea,http://127.0.0.1:8092/
nas-web,http://127.0.0.1:8093/


The script

#!/usr/bin/env bash
# uptime-check.sh - ping endpoints from a CSV, alert only on state changes.
set -u

TARGETS_FILE="${TARGETS_FILE:-/etc/uptime-check/targets.csv}"
STATE_FILE="${STATE_FILE:-/var/tmp/uptime-check.state}"
BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
TELEGRAM_API="${TELEGRAM_API:-https://api.telegram.org}"
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-5}"
MAX_TIME="${MAX_TIME:-10}"

log() { printf "%(%Y-%m-%d %H:%M:%S)T %s\n" -1 "$*"; }

notify() {
    if [[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]]; then
        curl -s -m 10 "$TELEGRAM_API/bot$BOT_TOKEN/sendMessage" \
            --data-urlencode "chat_id=$CHAT_ID" \
            --data-urlencode "text=$1" > /dev/null
        log "ALERT sent via telegram: $1"
    else
        log "ALERT (telegram not configured): $1"
    fi
}

probe() {
    curl -s -o /dev/null -w "%{http_code} %{time_total}" \
        --connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" "$1" 2>/dev/null
}

check_target() {
    local out code ms
    out="$(probe "$1")"; code="${out%% *}"
    if [[ ! "$code" =~ ^[23] ]]; then
        sleep 2
        out="$(probe "$1")"; code="${out%% *}"
    fi
    [[ -n "$code" ]] || code=000
    ms="$(awk -v t="${out##* }" "BEGIN { printf \"%.0f\", t * 1000 }")"
    if [[ "$code" =~ ^[23] ]]; then echo "UP $code $ms"; else echo "DOWN $code $ms"; fi
}

[[ -r "$TARGETS_FILE" ]] || { log "cannot read $TARGETS_FILE"; exit 2; }
touch "$STATE_FILE" || { log "cannot write $STATE_FILE"; exit 2; }

declare -A prev_state
while IFS="|" read -r key value; do
    [[ -n "$key" ]] && prev_state["$key"]="$value"
done < "$STATE_FILE"

declare -A new_state
ok_count=0; fail_count=0

while IFS=, read -r name url extra || [[ -n "${name:-}" ]]; do
    name="${name//[[:space:]]/}"; url="${url//[[:space:]]/}"
    [[ -z "$name" || "$name" == \#* ]] && continue
    read -r status code ms <<< "$(check_target "$url")"
    if [[ "$status" == UP ]]; then ok_count=$((ok_count + 1)); else fail_count=$((fail_count + 1)); fi
    log "$(printf "%-4s %-12s %-42s %-6s %sms" "$status" "$name" "$url" "$code" "$ms")"
    old="${prev_state[$name]:-new}"
    new_state["$name"]=$status
    if [[ "$old" == UP && "$status" == DOWN ]]; then
        notify "$name is DOWN (HTTP $code) - $url"
    elif [[ "$old" == DOWN && "$status" == UP ]]; then
        notify "$name is back UP (HTTP $code) after being down"
    elif [[ "$old" == new ]]; then
        log "baseline: $name first seen $status, no alert"
    fi
done < "$TARGETS_FILE"

: > "$STATE_FILE"
for key in "${!new_state[@]}"; do echo "$key|${new_state[$key]}" >> "$STATE_FILE"; done

log "check complete: $ok_count up, $fail_count down"
[[ "$fail_count" -eq 0 ]] || exit 1

Four design notes worth keeping:

  • A failed check retries once after two seconds, so a transient blip does not wake you up.
  • The first time a target is seen it is recorded but not alerted. That stops a flood after the monitoring box itself reboots.
  • The state file is just name|UP lines, easy to inspect when you are debugging.
  • Exit code is 1 if anything is down, which pairs cleanly with cron and systemd timers.


Testing the alert path without spamming your phone

Telegram alerts are awkward to verify: either you message yourself a dozen times or you skip testing and ship broken code. I made TELEGRAM_API an environment variable and pointed it at a 20-line Python stub that accepts POSTs, records the payload, and answers with the same JSON shape as the real API. The notify path then runs end to end offline. If you do not want a stub, leave the token empty: the script prints alerts to stdout instead.


Run one: baseline

First execution. jellyfin and gitea answer, nas-web is refused. All three are new, so the script records them and sends nothing:

2026-11-09 04:23:08 UP   jellyfin     http://127.0.0.1:8091/                     200    0ms
2026-11-09 04:23:08 baseline: jellyfin first seen UP, no alert
2026-11-09 04:23:08 UP   gitea        http://127.0.0.1:8092/                     200    1ms
2026-11-09 04:23:08 baseline: gitea first seen UP, no alert
2026-11-09 04:23:10 DOWN nas-web      http://127.0.0.1:8093/                     000    0ms
2026-11-09 04:23:10 baseline: nas-web first seen DOWN, no alert
2026-11-09 04:23:10 check complete: 2 up, 1 down


Run two: gitea dies

I killed the gitea server between runs and executed again. Exactly one alert fires, and nas-web, which is still down, stays silent because its state did not change:

2026-11-09 04:23:23 UP   jellyfin     http://127.0.0.1:8091/                     200    1ms
2026-11-09 04:23:25 DOWN gitea        http://127.0.0.1:8092/                     000    0ms
2026-11-09 04:23:25 ALERT sent via telegram: gitea is DOWN (HTTP 000) - http://127.0.0.1:8092/
2026-11-09 04:23:27 DOWN nas-web      http://127.0.0.1:8093/                     000    0ms
2026-11-09 04:23:27 check complete: 1 up, 2 down
Terminal output of uptime-check.sh showing gitea flagged DOWN and a Telegram alert sent, nas-web silenced as a known-down target
Only the changed target alerts. nas-web was already DOWN, so the script says nothing about it.


Run three: gitea recovers

Back online. The DOWN to UP transition produces the recovery notice, and the exit code stays 1 because nas-web never came back:

2026-11-09 04:24:06 UP   jellyfin     http://127.0.0.1:8091/                     200    0ms
2026-11-09 04:24:06 UP   gitea        http://127.0.0.1:8092/                     200    4ms
2026-11-09 04:24:06 ALERT sent via telegram: gitea is back UP (HTTP 200) after being down
2026-11-09 04:24:08 DOWN nas-web      http://127.0.0.1:8093/                     000    0ms
2026-11-09 04:24:08 check complete: 2 up, 1 down
Terminal output of uptime-check.sh showing gitea back UP with a recovery alert and a failed exit code because nas-web is still down
gitea recovered, the script says so once, and still exits 1 for the target that never came up.


Scheduling with cron

Every five minutes, as a drop-in under /etc/cron.d/. Before adding yet another entry, my cron and systemd timer hygiene audit is a useful detour:

# /etc/cron.d/uptime-check
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

*/5 * * * * root TARGETS_FILE=/etc/uptime-check/targets.csv TELEGRAM_BOT_TOKEN=123456:ABCDEF TELEGRAM_CHAT_ID=-1001234567890 /usr/local/bin/uptime-check.sh >> /var/log/uptime-check.log 2>&1

One verification gotcha I hit: piping the run through tee while checking the exit status showed 0 with two targets down, because the pipeline status belongs to tee. With set -o pipefail, or with no pipe at all, the script correctly returns 1.


Settings

VariableDefaultNotes
TARGETS_FILE/etc/uptime-check/targets.csvname,url lines
STATE_FILE/var/tmp/uptime-check.stateprevious status, key|status format
TELEGRAM_BOT_TOKENemptyalerts print to stdout when unset
TELEGRAM_CHAT_IDemptyuse -100… for a group
TELEGRAM_APIhttps://api.telegram.orgpoint at a local stub to test
CONNECT_TIMEOUT5seconds, curl
MAX_TIME10seconds, curl


If you want dashboards, history graphs, and thirty notification channels, the Uptime Kuma setup guide is still the right call. For watching five endpoints with zero containers and a phone that only buzzes on a real change, 75 lines of bash is enough. It has run from cron on that box ever since, and gitea has been down twice.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page