Tutorial

Telegram Alert When Server CPU Is High: Bash Script + Cron Tutorial

Create the bot, grab the chat ID, send one curl, then put a tested script with cooldowns on cron. Every command here ran on a real Ubuntu box first.

PulseNode · updated July 2026

Setting up a Telegram alert when server CPU is high takes a free bot, one curl command, and a short bash script on cron. No Python, no daemon. The path: create the bot with BotFather, dig your chat ID out of getUpdates, send a test message from bash, then install a tested script that checks CPU and RAM every minute, with a cooldown so it never spams you and a notice when things recover. Every command ran on a live Ubuntu box before it went into this page. The last section covers three ways this setup fails silently, because those matter more than the happy path.

The short version

1. Send /newbot to @BotFather and copy the token. 2. Message your new bot once, then read your chat ID from getUpdates. 3. Prove the pipe with one curl to sendMessage. 4. Install the script below on a one-minute cron. Steps 2 and 4 hide the classic mistakes: nobody messaged the bot, and the token ends up in world-visible process lists.

What you're building (30 seconds)

cron fires a bash script every minute. The script reads CPU from /proc/stat and RAM from free, compares both against thresholds, and POSTs a message to api.telegram.org over HTTPS when a line is crossed. A state file in /var/tmp stops repeats, and a recovery message tells you when the spike ends. You need bash, curl, a free Telegram account, and the procps tools, which mainstream distros preinstall. Nothing new listens on a port. Telegram's side costs nothing at this volume: bot limits sit around one message per second per chat, far above alert traffic.

Step 1: create the bot with BotFather

Open Telegram, find @BotFather, and send /newbot. It asks for a display name, then a username that has to end in bot, and it answers with an authentication token (Telegram's own BotFather docs walk through it). The token looks like this:

110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw

It goes straight into the URL of every API call, https://api.telegram.org/bot<TOKEN>/sendMessage, so anyone who sees that URL owns your bot. Treat it like a password. Where to store it comes up again before the cron step.

Step 2: get your chat ID with getUpdates

One rule shapes this step: bots cannot start a conversation. You message the bot first, or every send fails with a 403. Skipping this is the most common way the whole setup dies, so do it now: open your bot's chat, press Start, send it anything.

Then ask the API what it received:

curl -s "https://api.telegram.org/bot<TOKEN>/getUpdates"

# with jq installed, pull the ID straight out:
curl -s "https://api.telegram.org/bot<TOKEN>/getUpdates" | jq '.result[].message.chat.id'

# no jq on the box? grep works:
curl -s "https://api.telegram.org/bot<TOKEN>/getUpdates" \
  | grep -o '"chat":{"id":[-0-9]*' | grep -o '[-0-9]*$'

The reply is JSON with an ok flag and a result array of updates; your chat ID sits at result[].message.chat.id. For a private chat it is a positive number like 123456789.

If getUpdates returns an empty result

An empty result array almost always means nobody has messaged the bot yet. Send it a message and call getUpdates again. For a group alert channel: add the bot to the group, post any message there, re-run getUpdates, and copy the ID exactly as it comes back, minus sign included.

Step 3: send a test message with curl

Delivery is one HTTPS POST, which makes this the simplest way to send a Telegram message from bash:

curl -sS \
  --data-urlencode "chat_id=<CHAT_ID>" \
  --data-urlencode "text=test from $(hostname) [cpu 93%]" \
  "https://api.telegram.org/bot<TOKEN>/sendMessage"

A healthy reply starts with {"ok":true. Two details in that command earn their keep. --data-urlencode survives the %, spaces, and brackets that CPU alerts are full of; plain -d mangles them. And there is no parse_mode: sendMessage accepts HTML and Markdown formatting, but plain text needs no mode and never throws a parse error over an innocent [. (Text caps at 4096 characters, which a one-line alert will never see.) When the reply is not ok, it is almost always one of these:

API replyWhat it meansFix
401 UnauthorizedThe token is wrong or truncatedRecopy the whole token from BotFather, colon included
400 Bad Request: chat not foundThe chat_id is wrongRe-run getUpdates and copy the ID exactly, minus sign included
403 Forbidden: bot can't initiate conversation with a userNobody ever messaged the botOpen the bot's chat, press Start, retry

The script: a Telegram alert when server CPU or RAM is high

Reading CPU without extra packages

The first line of /proc/stat holds cumulative CPU counters since boot. One sample says nothing about now, so take two, one second apart, and work on the deltas: busy percent is 100 * (total delta minus idle delta) / total delta, with iowait counted as idle. Pure bash reads it, no packages involved.

Two popular alternatives fail quietly. top -bn1 reports a since-boot average in its first and only sample, so a box with two weeks of uptime can show single digits in the middle of a CPU fire; its output format also shifts between versions. Load average is not a percentage at all: 8.0 is a crisis on 2 cores and background noise on 32. mpstat measures correctly but ships in sysstat, a package our test box did not have; free and top come from procps (procps-ng on the RHEL family), which is effectively everywhere.

Reading RAM with the available column

The trap in free is the free column. Linux fills idle RAM with page cache on purpose, so a healthy server always looks nearly out of memory by that number: our test box showed 919 MB free next to 5.3 GB available. The column that predicts trouble is available. Percent used is total minus available, over total:

free | awk '/^Mem:/ {printf "%d\n", ($2-$7)/$2*100}'

That printed 54 on the test box, the same figure the free -m columns give by hand.

The full script, cooldowns and recovery included

First give the secrets a home that process lists and shell history cannot see; the reasons are in the failure section below:

sudo touch /etc/telegram-alert.env
sudo chmod 600 /etc/telegram-alert.env
sudo nano /etc/telegram-alert.env    # then add both lines:

BOT_TOKEN="the token from BotFather"
CHAT_ID="the ID from getUpdates"

Now the script itself. Save it as cpu-ram-alert.sh:

#!/usr/bin/env bash
# cpu-ram-alert.sh: Telegram alert when CPU or RAM crosses a threshold.
# Secrets live in /etc/telegram-alert.env (owner root, mode 0600):
#   BOT_TOKEN="110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw"
#   CHAT_ID="123456789"
set -u
. /etc/telegram-alert.env

CPU_LIMIT=85    # percent
RAM_LIMIT=90    # percent
COOLDOWN=900    # seconds to stay quiet after an alert (15 min)
STATE_DIR=/var/tmp

send() {
  curl -sS --max-time 10 \
    --data-urlencode "chat_id=${CHAT_ID}" \
    --data-urlencode "text=$1" \
    "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" > /dev/null
}

cpu_pct() {
  # Two /proc/stat samples, 1 second apart. Idle time includes iowait.
  local u n s i w q sq st u2 n2 s2 i2 w2 q2 sq2 st2 idle total
  read -r _ u n s i w q sq st _ < /proc/stat
  sleep 1
  read -r _ u2 n2 s2 i2 w2 q2 sq2 st2 _ < /proc/stat
  idle=$(( (i2 + w2) - (i + w) ))
  total=$(( (u2+n2+s2+i2+w2+q2+sq2+st2) - (u+n+s+i+w+q+sq+st) ))
  echo $(( total > 0 ? 100 * (total - idle) / total : 0 ))
}

check() {
  local name=$1 value=$2 limit=$3 last=0 now
  local state="${STATE_DIR}/telegram-alert-${name}"
  now=$(date +%s)
  if [ "$value" -ge "$limit" ]; then
    [ -f "$state" ] && last=$(cat "$state")
    if [ $(( now - last )) -lt "$COOLDOWN" ]; then
      return    # alerted recently, stay quiet
    fi
    send "ALERT $(hostname): ${name} at ${value}% (limit ${limit}%)"
    echo "$now" > "$state"
  elif [ -f "$state" ]; then
    send "OK $(hostname): ${name} back to ${value}% (limit ${limit}%)"
    rm -f "$state"
  fi
}

check cpu "$(cpu_pct)" "$CPU_LIMIT"
check ram "$(free | awk '/^Mem:/ {printf "%d", ($2-$7)/$2*100}')" "$RAM_LIMIT"

It went through all four phases on a live box: a breach sends one alert and records the time, an immediate re-run stays silent, a dip back under the limit sends a recovery notice and clears the state, and a healthy run does nothing. State is two small files in /var/tmp holding epoch timestamps. 85 for CPU and 90 for RAM are sane starting points; tune them to your workload.

Put it on cron

sudo cp cpu-ram-alert.sh /usr/local/bin/
sudo chmod 700 /usr/local/bin/cpu-ram-alert.sh
sudo crontab -e

# add this line: run every minute, log to a file
* * * * * /usr/local/bin/cpu-ram-alert.sh >> /var/log/cpu-ram-alert.log 2>&1

Every minute is not overkill. The script runs for about a second and the cooldown keeps your phone quiet. Use root's crontab, because the env file is root-owned by design. And note the absolute path: cron runs jobs with a minimal environment, so nothing in that line should rely on your login PATH.

Three ways this DIY setup fails

The script works. These are the ways it stops working, and the classic tutorials skip all three.

Threshold flapping spams your phone

A bursty workload sits at 84, 92, 87, 91 across four minutes. The cooldown handles the pinned case: while CPU stays above the limit you get one alert per 15 minutes and one recovery notice at the end. It does not fix oscillation across the line, where each dip sends a recovery and clears the state, so the next breach alerts again. The fix is hysteresis: count the metric as recovered only once it falls well below the limit. In this script that is one condition:

elif [ -f "$state" ] && [ "$value" -lt $(( limit - 10 )) ]; then

With that margin, an 85% limit alerts at 85 and recovers below 75; everything in between is silence. (Also tested; it drops straight in.)

The monitor dies with the patient

The design asks the failing machine to report its own failure. At load 60, or mid-OOM, cron may not fire on schedule, and when it does, curl can hang while the kernel kills processes around it. The alert you want most, "this box is unresponsive", is the one this design structurally cannot send. No on-box script escapes that. The fix is a watcher somewhere else that expects a regular signal and alarms on silence, a dead man's switch. That pattern has its own write-up: how to get notified when a server goes offline.

Your bot token leaks

The token is the bot: whoever reads it controls it, and it sits right in the URL. The fastest way to lose it is a command line, because any local user can read every process's full arguments:

$ ps -eo args | grep api.telegram.org
curl https://api.telegram.org/bot110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw/sendMessage ...

Put the token in a crontab line and it flashes into that list on every run. The crontab file itself is not the leak; on Debian and Ubuntu the spool under /var/spool/cron/crontabs is not world-readable. The leaks are argv and your shell history, plus whatever your editor writes to temp files. The setup above dodges them: the token lives in a root-owned 0600 file, the script sources it at runtime, and the cron line carries no secrets.

The off-box alternative: rules like "cpu > 85%" with Telegram built in

This is the part where we pitch our own product, so discount accordingly. PulseNode runs the same loop off the box. A single static Go binary pushes CPU, RAM, and disk numbers out over HTTPS about every 30 seconds; it never listens on a port. The rule you write reads like the script's thresholds:

cpu > 85%      cooldown 15 min

Cooldowns are per rule (1 to 1440 minutes, default 15) and a recovery notice goes out when the value drops back under the line. That is the state-file dance from above, run where a load spike cannot reach it. Telegram is a first-class channel: connect it in settings, paste a chat ID, and there is no bot server to host. And because the dashboard notices when an agent stops pushing, an agent-offline alert fires with no rule at all. That is failure mode two solved: silence itself trips the alarm.

The caveats. Setup is not zero-config: you enable a channel and create the rules yourself. It is hosted only, not open source, with no public API or data export today, and it is a young product run by one founder. Rules cover CPU, RAM, and disk percent, nothing more exotic. Hobby is €5/mo for 5 servers with 7 days of history; Pro is €15/mo for 20 servers and 30 days, billed through Lemon Squeezy, cancel anytime. For one hobby box under your full control, keep the script above; it costs nothing. PulseNode starts to earn its fee at several servers, or when the alert has to survive the exact failure it warns about.

Get the alert even when the box is on fire

Rules like cpu > 85%, Telegram delivery off the box, and an agent-offline alert when the pushes stop.

Start monitoring for €5/mo → Pro: 20 servers for €15/mo

FAQ

How do I get a Telegram alert when my server CPU is high?

Create a bot with BotFather, message it once, and read your chat ID from getUpdates. Then run a bash script on cron that samples /proc/stat and calls the sendMessage API with curl when usage crosses your threshold. The script on this page does exactly that, with a 15 minute cooldown and a recovery notice.

How do I find my Telegram chat ID for a bot?

Send your bot any message first, then call https://api.telegram.org/bot<TOKEN>/getUpdates. Your chat ID is the number at result[].message.chat.id. Copy it exactly as returned, including the leading minus sign if one is there.

Why is my Telegram bot not sending messages (403 or chat not found)?

A 403 reading "bot can't initiate conversation with a user" means nobody ever messaged the bot: open its chat, press Start, and retry. A 400 with "chat not found" means the chat_id is wrong or missing its minus sign. A 401 means the token is wrong or truncated.

Is the Telegram Bot API free to use for server alerts?

Yes. The Bot API costs nothing, and its rate limits (about one message per second per chat, 20 per minute to a group) sit far above what threshold alerts produce. A script with a cooldown sends a handful of messages on a bad day.

Should I alert on load average or CPU percentage?

CPU percentage. Load average counts runnable and waiting tasks, so it is not a percentage, and the same number means different things on 2 cores and on 32. A percentage computed from two /proc/stat samples reads the same on any box.

What if the server is too overloaded to send its own alert?

Then the script cannot help you, and no on-box script can. You need a watcher off the box that treats silence as the alarm. Build one with the heartbeat pattern, or use a hosted monitor like PulseNode, where the agent pushes metrics out and an agent-offline alert fires when the pushes stop.

See also