Articles › Servers and DevOps

A small server that reports on Telegram: status, alerts and a graceful shutdown

Sixty lines of Python on a small box: CPU, memory and disk status into Telegram, alerts that do not repeat, and a scheduled shutdown that stops containers in order.

Beginner 10 min read October 21, 2026

What you will learn
  • The Telegram Bot API is one HTTPS POST; Python's urllib is enough, no library needed
  • Alerts need state about what has already been reported, or every 5 minutes brings the same message until you mute the bot
  • The bot token lives in an env file outside the repo with mode 600; a token that was ever committed gets revoked at BotFather
  • A graceful shutdown stops containers in order, syncs, then powers off, and says so before it goes

A small machine running several important things, whether a single VPS or a box at home, usually has nobody watching its screen. You find out the disk is full when the database can no longer write. Our home server therefore has one small script that does three jobs: send a status report into Telegram on demand or on a schedule, alert when any value crosses a threshold, and shut the machine down gracefully at a set time every day. This article rebuilds its structure using only Python’s standard library.

Tested on Ubuntu 24.04, Python 3.12 and Docker Compose v2. The 2 GB box article covered setting caps; this one is about knowing in time when something touches a cap.

Why Telegram

  • Free, and it pushes to your phone immediately; no SMTP to set up, no app of your own
  • The Bot API is one HTTPS POST to https://api.telegram.org/bot<BOT_TOKEN>/sendMessage with chat_id and text; no library required
  • One-way is enough. Our bot only talks; it takes no commands. That removes most of the security questions (see the end)
cron, every 5 minutesread CPU, RAM, disk, tempsOver a threshold and not yet alerted?state file, so no spamMessage the Telegram botone HTTPS POSTgraceful shutdown: stop containers → sync → shutdownand tell Telegram before going dark
One alert per problem, until it clears

Create the bot and find the chat id

Done once, in the Telegram app:

  1. Message the bot called BotFather, type /newbot, give it a name and a username (which must end in bot). You get a one-line token: digits, a colon, then a long string
  2. Message your own bot, press Start and send any text. A bot cannot open a conversation; you have to go first
  3. On your machine, call getUpdates and read chat.id
# any machine with curl
curl -s "https://api.telegram.org/bot$BOT_TOKEN/getUpdates" | grep -o '"chat":{"id":[0-9-]*'

That number is the CHAT_ID of your private chat with the bot. To post into a group, invite the bot to the group and repeat step 3; a group id is negative.

Keep the token in the right place

The token is everything: whoever has it can send messages as your bot. So it never goes in the script and never in the repo. Keep it in a root-owned file that only root can read.

# on the server, as root
cat > /etc/monitor.env <<'EOF'
export BOT_TOKEN=123456:REPLACE_ME
export CHAT_ID=REPLACE_ME
EOF
chmod 600 /etc/monitor.env
mkdir -p /var/lib/monitor

If the token was ever committed anywhere, even if the commit was later removed, treat it as leaked. In BotFather type /mybots, pick the bot, open API Token and choose Revoke current token. You get a new token and the old one stops working immediately.

One script, three modes

The file /usr/local/bin/monitor.py uses only the standard library and reads /proc and /sys directly; no psutil needed.

#!/usr/bin/env python3
"""monitor.py — status | alert | shutdown via Telegram (stdlib only)"""
import json, os, shutil, subprocess, sys, urllib.request
from pathlib import Path

TOKEN = os.environ["BOT_TOKEN"]          # from /etc/monitor.env (chmod 600)
CHAT_ID = os.environ["CHAT_ID"]
LIMITS = {"load": 2 * os.cpu_count(), "ram": 85, "disk": 90, "temp": 85}
STATE = Path("/var/lib/monitor/alerts.json")   # remembers what was already reported

def send(text):
    body = json.dumps({"chat_id": CHAT_ID, "text": text}).encode()
    req = urllib.request.Request(f"https://api.telegram.org/bot{TOKEN}/sendMessage",
                                 data=body, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=10) as r:
        return r.status == 200

def load():                      # 1-minute load average
    return float(open("/proc/loadavg").read().split()[0])

def ram_pct():                   # MemAvailable, not MemFree
    m = {l.split()[0].rstrip(":"): int(l.split()[1]) for l in open("/proc/meminfo")}
    return 100 * (1 - m["MemAvailable"] / m["MemTotal"])

def disk_pct(path="/"):
    d = shutil.disk_usage(path)
    return 100 * d.used / d.total

def cpu_temp():                  # most VPSes have none -> None
    try:
        return int(open("/sys/class/thermal/thermal_zone0/temp").read()) / 1000
    except OSError:
        return None

def metrics():
    return {"load": load(), "ram": ram_pct(), "disk": disk_pct(), "temp": cpu_temp()}

def status():
    m = metrics()
    t = f"{m['temp']:.0f}°C" if m["temp"] else "n/a"
    return f"server ok\nload {m['load']:.2f} · ram {m['ram']:.0f}% · disk {m['disk']:.0f}% · cpu {t}"

def alert():
    m = metrics()
    bad = {k: v for k, v in m.items() if v is not None and v > LIMITS[k]}
    prev = json.loads(STATE.read_text()) if STATE.exists() else {}
    new = [k for k in bad if k not in prev]          # just crossed the line
    cleared = [k for k in prev if k not in bad]      # back to normal
    if new:
        send("ALERT " + ", ".join(f"{k} {bad[k]:.1f} > {LIMITS[k]}" for k in new))
    if cleared:
        send("ok again: " + ", ".join(cleared))
    STATE.write_text(json.dumps(bad))

def shutdown():
    send("shutting down (graceful) ...")
    subprocess.run(["/usr/local/sbin/shutdown-graceful.sh"], check=True, timeout=300)

if __name__ == "__main__":
    cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
    {"status": lambda: send(status()), "alert": alert, "shutdown": shutdown}[cmd]()

Try it by hand before scheduling anything:

# on the server, as root
chmod 755 /usr/local/bin/monitor.py
. /etc/monitor.env && monitor.py status

If the message reaches your phone, the token, chat id and network all work.

Decisions baked into the code

ValueRead fromNote
loadfirst field of /proc/loadavgload instead of CPU % needs no 1-second sample and says more about queued work
ram/proc/meminfomust use MemAvailable; with MemFree you alert all day, because Linux always turns free memory into cache
diskshutil.disk_usage("/")a machine with a separate data disk (say /mnt/data) gets one more line
temp/sys/class/thermal/thermal_zone0/tempin thousandths of a degree; zone0 may not be the CPU, check cat /sys/class/thermal/thermal_zone*/type. If there is none, apt install lm-sensors and use sensors -j

The real script at home also reads CPU % from two /proc/stat samples one second apart, GPU temperature from nvidia-smi, and counts containers that are not running from docker ps -a. Same structure, just more reader functions.

Choosing thresholds

ValueThresholdReason
load (1 min)2 × coresload equal to the core count is exactly full; twice that means real queueing
ram85%leaves room for a spike before the OOM killer acts
disk90%at 90% you still have days to delete logs; at 98% usually minutes
CPU temp85°Ca home box has no air conditioning; most chips start throttling around 90-100°C

Work it through with real numbers. An 877 GB disk at 90% has 87 GB left; with logs growing 2 GB a day that is 40-odd days. A 25 GB VPS disk at 90% has 2.5 GB left; at 2 GB a day that is one day. Small disks deserve a lower threshold, say 80%.

Scheduling: alerts every 5 minutes, one summary a day

# /etc/cron.d/monitor  (root, because shutdown needs it)
*/5 * * * *  root  . /etc/monitor.env && /usr/local/bin/monitor.py alert
0 8 * * *    root  . /etc/monitor.env && /usr/local/bin/monitor.py status
0 18 * * *   root  . /etc/monitor.env && /usr/local/bin/monitor.py shutdown

The heart of alert mode is alerts.json, which remembers which values are already over the line. The next run sends only values that have just crossed it, and sends “ok again” when one returns to normal. Without that file, one full disk produces the same message 288 times a day, you mute the bot, and you are back to having no monitoring.

The daily summary is more useful than it looks: it is proof the bot is alive. A morning without the 8 o’clock message is an alert in itself.

Graceful shutdown

Our home box has a cron job that shuts it down around 18:00 every day; it is switched back on by hand at the start of work, so memory, logs and any stuck container start fresh. The thing to avoid is powering off while the database is writing. A bare shutdown -h now sends SIGTERM to every container at once; the app may still be writing into a database that is already closing.

#!/bin/bash
# /usr/local/sbin/shutdown-graceful.sh  (chmod 700, root)
set -e
cd /srv/app
docker compose stop -t 30 app worker   # the ones that create work stop first
docker compose stop -t 60 redis db     # the ones that store it stop last
sync                                   # flush buffers to disk
shutdown -h now

The order: stop what produces work, then stop what stores it. -t is how long a container gets to exit on its own before Docker kills it; the database deserves more than the app. sync forces whatever is still in the kernel’s buffers onto the disk.

The Python script sends “shutting down” before calling this script, because afterwards there is no network to send from. The message also helps you tell events apart: if the machine disappears without it, that was a power cut or a hang, not the schedule.

You can trigger it any time with monitor.py shutdown over SSH, no need to wait for the hour.

What not to do

  • Do not take commands from Telegram back to the server without authentication. Calling getUpdates and acting on whatever arrives means anyone who finds the bot’s username can drive the machine. If you really want it, check from.id against an allowlist on every message, and deal with getUpdates handing you the same message again
  • Do not flood. Telegram says a bot should not send more than about 1 message per second in one chat, and no more than 20 per minute in a group; beyond that you get HTTP 429. The state file above keeps us nowhere near those numbers
  • Do not put internal details in messages. Internal IPs, usernames, paths to secrets. Telegram messages sit on someone else’s server and stay on your phone; send only the numbers you need to decide on
  • Do not let cron run without a timeout. urlopen(..., timeout=10) matters; if the network hangs, a script that hangs every 5 minutes piles up on itself

Common mistakes

  • Using MemFree instead of MemAvailable and getting memory alerts all day
  • The token in a config.json that git add . picked up
  • cron not seeing the variables because . /etc/monitor.env was left off the command
  • thermal_zone0 is a motherboard sensor, not the CPU, so it never alerts
  • A bare shutdown, and the database comes up in recovery mode the next morning

Sources

FAQ

Why not Grafana or Uptime Kuma?

We look after a handful of machines, and the questions are only "is it fine right now?" and "is anything over a threshold?". Sixty lines answer both without feeding another monitoring system on a small box. With ten or more machines, think again.

Can I control the server from Telegram, say by typing /reboot?

You can, but not as a first project. Anyone who can message the bot could control the machine, so you must check the sender's chat id against an allowlist on every message and handle repeated commands. This article keeps the bot one-way; commands go over SSH, which already authenticates you.

Commands and settings were tested on sample systems. Try them on a test machine before production.