Host a Telegram Bot 24/7 on a VPS: systemd, PM2, Webhooks

Published on

Keep a Telegram bot online on a VPS: long polling or webhook, a Python and a Node.js bot under systemd or PM2, a root-only token file, logs and sizing.

A long data center aisle between rows of server racks, with glowing blue and purple cables running overhead

Your Telegram bot answers while your laptop is on and goes quiet when it sleeps. Telegram keeps a bot's unread updates for 24 hours at most, so a longer outage loses messages for good. On a VPS the bot runs as a service that comes back after a crash and after a reboot. This guide covers polling versus webhooks, a Python and a Node.js example, systemd or PM2, a root-only token file, logs and sizing, with commands for Ubuntu 24.04 and Debian 12.

Quick answer: Use long polling unless you need a webhook. Polling needs no domain, certificate or open port, only outgoing HTTPS. A webhook needs HTTPS on port 443, 80, 88 or 8443 with TLS 1.2 or newer, over IPv4. Create a system user, put BOT_TOKEN=... in a root-only /etc/tgbot.env, and run the bot from a systemd unit with User=, EnvironmentFile= and Restart=on-failure, enabled with systemctl enable --now. The logs are in journalctl -u tgbot. Run one copy per token, or Telegram answers 409 Conflict.

Long Polling or Webhook?

The Bot API has two mutually exclusive ways to deliver updates: while a webhook is set, getUpdates doesn't work. With long polling, your bot asks for updates and the request stays open until something arrives or a timeout passes (30 seconds by default in grammY, 10 in python-telegram-bot). With a webhook, Telegram POSTs every update to an HTTPS URL you registered.

Long pollingWebhook
Who connectsYour bot, out to api.telegram.orgTelegram, in to your server
Domain and certificateNot neededVerified or self-signed certificate whose CN or SAN matches
Inbound portNone443, 80, 88 or 8443
IP versionNot relevantIPv4 only
Load controlYour code decides when to fetchmax_connections, 1 to 100, default 40

grammY's comparison sums it up: long polling is simpler, webhooks are cheaper, and if in doubt, "go for long polling". The webhook savings it lists, such as scaling down to zero, pay off on serverless platforms rather than on a VPS that runs anyway, and python-telegram-bot's webhook guide says to switch only with a good reason.

Step 1: Get a Token and Prepare the Server

Open @BotFather, send /newbot, pick a name and a username ending in bot, and copy the token. It looks like 110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw, and Telegram's BotFather guide warns that anyone who has it can control your bot.

On the VPS, create a system user without a login shell, because grammY's VPS guide recommends running each application as its own non-root user. Then install the Python tools:

sudo useradd --system --create-home --home-dir /opt/tgbot --shell /usr/sbin/nologin --user-group tgbot
sudo apt update
sudo apt install -y python3-venv curl

Ubuntu 24.04 ships Python 3.12 and Debian 12 ships 3.11; python-telegram-bot 22.8 needs 3.10 or newer. For Node.js, skip the distributions' own nodejs package: it is version 18 on both, and Node.js 18 reached end of life on 30 April 2025. On 30 October 2026, NodeSource's LTS script installs Node.js 24, supported until 30 April 2028:

curl -fsSL https://deb.nodesource.com/setup_lts.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
node -v

Step 2: Put the Token in a Root-Only File

Keep the token out of your code. Create the file, lock it down before it holds anything, then open it:

sudo touch /etc/tgbot.env
sudo chmod 600 /etc/tgbot.env
sudo nano /etc/tgbot.env

Add one line, without quotes:

BOT_TOKEN=110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw

ls -l /etc/tgbot.env now shows -rw------- and root root. The bot user can't read the file and doesn't need to: systemd's service manager, running as root, reads EnvironmentFile= before it starts the process as User=. Check the token with getMe, the Bot API's token test:

sudo bash -c 'source /etc/tgbot.env && curl -s "https://api.telegram.org/bot${BOT_TOKEN}/getMe"'

A working token returns "ok":true with your bot's username. A revoked or mistyped one returns {"ok":false,"error_code":401,"description":"Unauthorized"}, and a malformed one returns error 404, Not Found.

Step 3a: A Python Bot with python-telegram-bot

Install the library in a virtual environment owned by the bot user. A system-wide pip install, which python-telegram-bot's hosting page still shows, stops on both systems with error: externally-managed-environment, because Debian and Ubuntu mark their Python as externally managed:

sudo -H -u tgbot python3 -m venv /opt/tgbot/venv
sudo -H -u tgbot /opt/tgbot/venv/bin/python -m pip install python-telegram-bot

Save this as /opt/tgbot/bot.py (sudo nano /opt/tgbot/bot.py). It is the library's echobot example, trimmed, with the token read from the environment:

import logging
import os

from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters

logging.basicConfig(format="%(levelname)s %(name)s: %(message)s", level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text("Hi, I run on a VPS.")


async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(update.message.text)


def main() -> None:
    app = Application.builder().token(os.environ["BOT_TOKEN"]).build()
    app.add_handler(CommandHandler("start", start))
    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
    app.run_polling(allowed_updates=Update.ALL_TYPES)


if __name__ == "__main__":
    main()

run_polling() shuts the bot down gracefully on SIGINT, SIGTERM and SIGABRT, and SIGTERM is what systemctl stop sends.

Step 3b: A Node.js Bot with grammY

Create the project as the bot user and install grammY (version 1.46.0 on 30 October 2026):

sudo -H -u tgbot bash -c 'cd /opt/tgbot && npm init -y && npm install grammy'

Save this as /opt/tgbot/bot.js (sudo nano /opt/tgbot/bot.js):

const { Bot } = require("grammy");

const bot = new Bot(process.env.BOT_TOKEN);

bot.command("start", (ctx) => ctx.reply("Hi, I run on a VPS."));
bot.on("message:text", (ctx) => ctx.reply(ctx.message.text));

bot.catch((err) => {
  console.error(`Error while handling update ${err.ctx.update.update_id}:`, err.error);
});

process.once("SIGINT", () => bot.stop());
process.once("SIGTERM", () => bot.stop());

bot.start();

grammY's error guide explains bot.catch: without it, the default handler stops a bot started with bot.start() on the first error in a handler. The signal handlers follow grammY's graceful shutdown advice, so systemctl stop ends the polling loop through bot.stop().

Step 4: Run It as a systemd Service

Create /etc/systemd/system/tgbot.service with sudo nano:

[Unit]
Description=Telegram bot
Wants=network-online.target
After=network-online.target

[Service]
Type=exec
User=tgbot
Group=tgbot
WorkingDirectory=/opt/tgbot
EnvironmentFile=/etc/tgbot.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/tgbot/venv/bin/python /opt/tgbot/bot.py
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes

[Install]
WantedBy=multi-user.target

For the grammY bot, use ExecStart=/usr/bin/node /opt/tgbot/bot.js, where NodeSource's package installs node, and drop the PYTHONUNBUFFERED line. What the lines do:

  • Wants= and After=network-online.target wait for the network. systemd's network page says this target is meant for client software that can't work without the network, like a polling bot, and python-telegram-bot doesn't retry its startup calls by default (bootstrap_retries=0).
  • Type=exec makes systemctl start fail when the program or the user is missing; simple would report success.
  • Restart=on-failure is the manual's recommended choice for long-running services: it restarts after a non-zero exit, a crash signal or an out-of-memory kill, but not after systemctl stop.
  • RestartSec=5 replaces the 100 ms default. systemd refuses a unit that starts more than 5 times in 10 seconds; 5 seconds apart, that never happens, so the bot keeps retrying through an outage.
  • PYTHONUNBUFFERED=1: Python block-buffers stdout when it isn't a terminal, so print() output would reach the log late. Logging goes to stderr, which is line-buffered.
  • NoNewPrivileges=yes stops the bot and its child processes from gaining privileges through setuid or setgid programs.

Load and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now tgbot
systemctl status tgbot

enable --now starts the bot and adds it to the boot. Send /start to your bot, then run sudo reboot, log back in and check that systemctl status tgbot shows active (running).

Logs, Updates and Restarts

Everything the bot writes to stdout and stderr lands in the journal, which root and members of the adm and systemd-journal groups can read:

sudo journalctl -u tgbot -f                  # follow live output, Ctrl+C to stop
sudo journalctl -u tgbot -n 100 --no-pager   # the last 100 lines
sudo journalctl -u tgbot --since today
sudo systemctl restart tgbot                 # after you change bot.py or bot.js

You don't need logrotate: by default journald caps its disk use at 10% of the file system, and at 4 GB at most. systemctl status tgbot also shows a Memory: line, which you'll want for sizing.

Prefer PM2?

PM2 is designed for Node.js apps, so it suits the grammY bot if you already use it; for the Python bot, systemd is simpler. Run PM2 as your normal user with the project in ~/tgbot (bot.js plus npm install grammy), keep the token in ~/tgbot/.env (chmod 600 .env), and let Node's --env-file flag (Node.js 20.6 or newer) load it:

sudo npm install -g pm2

Then create ecosystem.config.js next to bot.js:

module.exports = {
  apps: [{
    name: "tgbot",
    script: "bot.js",
    cwd: "/home/YOUR_USER/tgbot",
    node_args: "--env-file=/home/YOUR_USER/tgbot/.env",
  }],
};
cd ~/tgbot
pm2 start ecosystem.config.js
pm2 startup                  # then run the sudo command it prints
pm2 save
pm2 logs tgbot --lines 50
pm2 install pm2-logrotate

pm2 startup prints a sudo env PATH=... command that registers PM2 with systemd, and pm2 save stores the process list for the next boot. After a Node.js upgrade, run pm2 unstartup and pm2 startup again, as PM2's startup docs advise. pm2-logrotate keeps PM2's own log files in check, and our PM2 guide for bots covers restart limits and memory caps. Use PM2 or systemd, never both, or the two copies conflict.

Switching to a Webhook

Let nginx handle TLS on port 443 and pass requests to the bot on a local port, as python-telegram-bot's webhook guide does. Install the webhook extra and generate a secret:

sudo -H -u tgbot /opt/tgbot/venv/bin/python -m pip install "python-telegram-bot[webhooks]"
python3 -c 'import secrets; print(secrets.token_hex(32))'

Add the printed value to /etc/tgbot.env as WEBHOOK_SECRET=... and replace the run_polling(...) line in bot.py with:

    app.run_webhook(
        listen="127.0.0.1",
        port=8081,
        url_path="tg-webhook",
        secret_token=os.environ["WEBHOOK_SECRET"],
        webhook_url="https://bot.example.com/tg-webhook",
        allowed_updates=Update.ALL_TYPES,
    )

In the nginx server block for bot.example.com, once it has a certificate (our nginx reverse proxy guide sets one up with certbot), add:

location /tg-webhook {
    proxy_pass http://127.0.0.1:8081;
}

run_webhook() calls setWebhook for you and answers 403 to any request without the matching X-Telegram-Bot-Api-Secret-Token header. Use an A record: Telegram's webhook guide says IPv6 is currently not supported for webhooks, and every Space-Node VPS has an IPv4 address next to its IPv6. Check delivery with:

sudo bash -c 'source /etc/tgbot.env && curl -s "https://api.telegram.org/bot${BOT_TOKEN}/getWebhookInfo"'

pending_update_count counts the updates waiting for delivery, and last_error_message explains the last failure. In grammY, webhookCallback() receives the updates inside Express, Fastify or Node's http module and you call setWebhook yourself, as its webhook section shows.

How Much Server Does a Bot Need?

grammY's scaling page says most bots never process more than a handful of messages per minute, even at peak. With long polling, both libraries handle one update at a time unless you enable concurrency (grammY runner, or concurrent_updates() in python-telegram-bot). Telegram limits sending anyway: about one message per second in a chat, 20 per minute in a group and about 30 per second for broadcasts, per the Bots FAQ. What drives the size is the work in your handlers:

  • Files. Through api.telegram.org, bots download files up to 20 MB and send up to 50 MB. A local Bot API server drops the download limit and allows uploads up to 2000 MB, and then disk space and bandwidth count.
  • Processing. Video conversion, image rendering or an AI model running on the server need CPU cores and RAM to match.
  • State. grammY's deployment checklist warns that session data without a storage adapter is lost when the process stops, so budget for a database.

Note the Memory: line during busy hours and add MemoryMax= to the unit with headroom above it. A leak past that limit triggers the out-of-memory killer inside the unit only, and Restart=on-failure brings the bot back. Location matters for latency too: grammY's API guide says the Bot API server behind api.telegram.org is hosted in Amsterdam. Our VPS plans run in an EU data center with dedicated RAM, and you can upgrade to a bigger plan from the client area when memory keeps climbing.

If It Still Fails

  • 409 Conflict: terminated by other getUpdates request. Another copy polls with the same token, for example on your computer or in PM2 next to systemd. Find it with pgrep -af 'bot\.(py|js)' and pm2 list. If you can't, revoke the token in BotFather (/token generates a new one), which grammY's FAQ says stops all other instances, and update /etc/tgbot.env.
  • Unauthorized or Not Found at startup. python-telegram-bot raises InvalidToken for both: the token is wrong or revoked. Repeat the getMe test from step 2.
  • KeyError: 'BOT_TOKEN', or grammY reports an empty token. The variable never reached the process. Check the EnvironmentFile= path and the variable name in systemctl cat tgbot, then run sudo systemctl daemon-reload and restart.
  • The unit fails at once. systemctl status tgbot shows the exit status: 203 means the ExecStart= program couldn't be executed (check the venv or node path), 217 points at User= and 200 at WorkingDirectory=.
  • The bot ignores group chat. Privacy mode, on by default unless the bot is a group admin, limits it to commands meant for it, replies to its messages and service messages. Turn it off with /setprivacy in BotFather and add the bot to the group again, or make it an admin.
  • A webhook gets nothing. Read last_error_message from getWebhookInfo, check the port, certificate and A record, and make sure no polling copy runs: grammY's bot.start() and python-telegram-bot's run_polling() both call deleteWebhook when they start.

FAQ

Can the bot run on my PC and the VPS at the same time? Not with one token. Two polling copies get 409 Conflict, and a polling copy removes a webhook. Telegram's testing advice is to create a second bot in BotFather and use its token while you develop.

Do I need a domain and an SSL certificate? Only for a webhook; long polling only makes outgoing HTTPS requests. A webhook needs TLS 1.2 or newer, and with a self-signed certificate, uploaded through the certificate parameter of setWebhook, the certificate may name the server's IP address instead of a domain.

What happens to messages while the VPS reboots? Telegram keeps them for up to 24 hours and delivers them when the bot polls again. To skip that backlog, start with run_polling(drop_pending_updates=True) or bot.start({ drop_pending_updates: true }).

Is an environment file safe enough for the token? A root-only file is a sensible baseline, though the systemd.exec manual says environment variables are not suitable for secrets, partly because child processes inherit them. Stricter: put the bare token in a root-only file, add LoadCredential=bot_token:/etc/tgbot/token to the unit, and let the bot read the file bot_token in the folder that $CREDENTIALS_DIRECTORY points to, which only the service user and root can read.

Can I run several bots on one VPS? Yes: one user, token file and unit per bot, such as tgbot-shop.service and tgbot-alerts.service. For webhooks, one nginx server can route a path per bot to its own local port, which gets around the limit of four bots per IP address that python-telegram-bot's guide gives for its built-in server. Our Discord bot VPS guide uses the same kind of unit, with its own user and environment file, for a Discord bot.

Sources

All opened on 30 October 2026: