
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 polling | Webhook | |
|---|---|---|
| Who connects | Your bot, out to api.telegram.org | Telegram, in to your server |
| Domain and certificate | Not needed | Verified or self-signed certificate whose CN or SAN matches |
| Inbound port | None | 443, 80, 88 or 8443 |
| IP version | Not relevant | IPv4 only |
| Load control | Your code decides when to fetch | max_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=andAfter=network-online.targetwait 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=execmakessystemctl startfail when the program or the user is missing;simplewould report success.Restart=on-failureis 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 aftersystemctl stop.RestartSec=5replaces 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, soprint()output would reach the log late. Logging goes to stderr, which is line-buffered.NoNewPrivileges=yesstops 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 withpgrep -af 'bot\.(py|js)'andpm2 list. If you can't, revoke the token in BotFather (/tokengenerates a new one), which grammY's FAQ says stops all other instances, and update/etc/tgbot.env.UnauthorizedorNot Foundat startup. python-telegram-bot raisesInvalidTokenfor both: the token is wrong or revoked. Repeat thegetMetest from step 2.KeyError: 'BOT_TOKEN', or grammY reports an empty token. The variable never reached the process. Check theEnvironmentFile=path and the variable name insystemctl cat tgbot, then runsudo systemctl daemon-reloadand restart.- The unit fails at once.
systemctl status tgbotshows the exit status: 203 means theExecStart=program couldn't be executed (check the venv or node path), 217 points atUser=and 200 atWorkingDirectory=. - 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
/setprivacyin BotFather and add the bot to the group again, or make it an admin. - A webhook gets nothing. Read
last_error_messagefromgetWebhookInfo, check the port, certificate and A record, and make sure no polling copy runs: grammY'sbot.start()and python-telegram-bot'srun_polling()both calldeleteWebhookwhen 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:
- Telegram: Bot API (getting updates, getUpdates, setWebhook, getWebhookInfo, getMe, getFile, local Bot API server), Webhooks guide, Bots FAQ, Bot features: BotFather, privacy mode, testing, Bot tutorial
- python-telegram-bot: PyPI, Application, echobot example, wiki: Webhooks, Concurrency, Hosting your bot, source v22.8: request errors, updater, webhook handler
- grammY: Getting started, Long polling vs. webhooks, Hosting: VPS, Error handling, Reliability, Scaling up II, Deployment checklist, FAQ, Bot API guide, Filter queries, bot.ts v1.46.0, release v1.46.0
- systemd: systemd.service, systemd.exec, systemd.unit, systemd.kill, systemd.resource-control, systemd-system.conf, systemctl, journalctl, journald.conf, Network configuration synchronization points, execute.c v255, Debian 12 systemd.exec
- PM2: Quick start, Startup script, Ecosystem file, Logs
- Node.js and npm: --env-file, release schedule, NodeSource distributions, npm init, npm config
- Python: venv, sys.stdout buffering, -u and PYTHONUNBUFFERED, logging, StreamHandler, secrets, pip's externally-managed error, Debian 12 release notes, Ubuntu 24.04 EXTERNALLY-MANAGED file
- Packages: Debian 12 nodejs, python3 and systemd; Ubuntu 24.04 nodejs, python3-defaults and systemd
- Man pages and nginx: useradd(8), sudo(8), pgrep(1), nginx proxy_pass