To run a Telegram bot on a VPS, install it as a systemd service on a small Linux server and choose how it receives updates: long polling (getUpdates) needs no domain, certificate or open port, while a webhook (setWebhook) needs a public HTTPS URL on port 443, 80, 88 or 8443. A Telegram bot VPS for a single text bot can be as small as 1 GB of RAM: Telegram’s flood limits cap how fast a bot can send, so you size for what your handlers do, not for Telegram. This guide uses Ubuntu 24.04 LTS, python-telegram-bot 22.8 and the official Bot API documentation, all checked on October 3, 2026.
Key takeaways
- Long polling (getUpdates) needs no domain, certificate or open port; a webhook (setWebhook) needs HTTPS on port 443, 80, 88 or 8443, over IPv4, with TLS 1.2 or newer.
- Run the bot as a systemd service with Restart=on-failure and load the token with LoadCredential=, which systemd recommends over environment variables for secrets.
- Set the httpx logger to WARNING in python-telegram-bot: at INFO it writes every Bot API URL, token included, into your logs.
- Only one process may poll a token at a time; a second one gets a 409 Conflict, so test with a separate bot.
- A single text bot fits a 1 GB server, Ubuntu's documented minimum for cloud images; size up for databases, media or AI calls, and once the bot runs 24/7 it never costs more than the plan's monthly price in a billing period.
Long polling vs webhook: which should your bot use?
Telegram offers two mutually exclusive ways to receive updates, and the Bot API keeps undelivered updates on its servers for at most 24 hours either way. With long polling your bot asks Telegram for updates; with a webhook Telegram pushes each update to your server as an HTTPS POST. The differences that matter on a VPS:
| Long polling (getUpdates) | Webhook (setWebhook) | |
|---|---|---|
| Who opens the connection | Your bot, outbound to api.telegram.org | Telegram, inbound to your server |
| Domain and TLS certificate | Not needed | Needed: a certificate whose CN or SAN matches the host (a self-signed certificate may use the IP) |
| Inbound port | None | 443, 80, 88 or 8443 only |
| IP version | No inbound address needed | IPv4 only; IPv6 is not supported for webhooks |
| Parallel delivery | One poller per token; a second one gets a 409 Conflict | max_ 1 to 100 simultaneous HTTPS connections (default 40) |
| Debugging | Errors appear in your own logs | getWebhookInfo shows pending_ and last_ |
| Works behind NAT or on a laptop | Yes | No: Telegram must reach a public IPv4 address |
| Moving parts on the VPS | Bot only | Bot + reverse proxy + DNS + certificate renewal |
Speed is rarely the deciding factor. Telegram’s webhook guide says a webhook may save CPU cycles and improve response time, but that “these things however depend heavily on the usage pattern of your bot”. A long poll already waits on an open request until an update arrives, so a small bot feels the same either way.
You use getUpdates at the moment and it works, keep it that way. […] There is nothing wrong with using getUpdates.
Telegram, Marvin’s Marvellous Guide to All Things Webhook
Choose long polling when:
- you have no domain, or you don’t want to manage TLS certificates;
- the bot runs as one process on one server (most bots);
- you are testing on a short-lived hourly VPS that you will delete tonight.
Choose a webhook when:
- the VPS already serves a website or API behind a reverse proxy, so ports 80 and 443 are open anyway;
- you want Telegram to deliver updates in parallel (up to 100 connections) to a busy bot;
- you plan to run several app instances behind one HTTPS endpoint.
The bot in this guide supports both modes from one file, so you can start with polling and switch later by changing two settings.
What does a Telegram webhook require?
Telegram’s webhook guide and Bot FAQ list the hard requirements. If one fails, delivery fails and getWebhookInfo records the reason.
- Ports: 443, 80, 88 or 8443. Other ports “are not supported and will not work”.
- IPv4: “IPv6 is currently not supported for webhooks”, so the hostname needs an A record.
- TLS 1.2 or newer: SSLv2/3, TLS 1.0 and TLS 1.1 are rejected. Plain-HTTP webhooks are not possible on any port.
- A matching certificate: the CN or a SAN must match the domain you register, with the full intermediate chain. The FAQ warns that wildcard certificates may not be supported.
- No redirects: the URL you register must answer directly, so register the final HTTPS URL.
- Source ranges: webhook requests come from 149.154.160.0/20 and 91.108.4.0/22, and Telegram notes the range “might change in the future”.
Tip: Verify webhook requests with secret_token instead of an IP allowlist. When you set it, Telegram sends it in the X-Telegram-Bot-Api-Secret-Token header of every request (1 to 256 characters, A-Z, a-z, 0-9, _ and -). python-telegram-bot answers 403 Forbidden to any request without the right header, and you avoid breaking delivery when Telegram’s IP ranges change.
Note: Telegram’s Bot API server is open source (tdlib/telegram-bot-api). Per the Bot API docs, a copy you run yourself downloads files of any size (the cloud limit is 20 MB), uploads up to 2,000 MB (cloud: 50 MB) and accepts webhooks on any port, over plain HTTP or to a local IP. You must call logOut first, and the bot cannot log back in to the cloud server for 10 minutes. Most bots never need this.
How much RAM and CPU does a Telegram bot need?
Neither Telegram nor python-telegram-bot publishes a RAM requirement, so size from two documented limits. First, Ubuntu Server 24.04 lists 1 GB of RAM as the minimum for cloud images (3 GB or more as the suggested minimum for general server use). Second, Telegram’s Bot FAQ caps what a bot can send: about 1 message per second in a single chat, 20 messages per minute in a group, and about 30 messages per second for bulk notifications unless you pay for broadcasts.
So the bot process is rarely the bottleneck; what your handlers do decides the plan: a database, media processing or calls to an LLM API. The table is reasoning, not a benchmark.
| Bot profile | What uses the resources | Starting plan | Monthly cap |
|---|---|---|---|
| Personal, alert or notification bot; long polling; no database | One Python process, mostly idle | Quartz Q1: 1 vCPU, 1 GB RAM | $5.00/month cap |
| Group or community bot with SQLite or Redis; webhook behind Caddy | Bot + Caddy + a small datastore | Quartz Q2: 1 vCPU, 2 GB RAM | $10.00/month cap |
| Several bots, PostgreSQL or Docker, image handling, AI agent front ends | Several processes, short CPU bursts | Quartz Q4: 2 vCPU, 4 GB RAM | $15.00/month cap |
| Sustained CPU work such as transcoding or local inference | Constant CPU load | Chrono C8: 2 dedicated vCPU, 8 GB RAM | $35.00/month cap |
After a day of traffic, systemctl status tgbot shows the bot’s real memory use on its Memory: line. Running a Discord bot too? The Discord bot hosting guide uses the same systemd pattern, so both can share one server.
Does server location matter for a Telegram bot?
Your users talk to Telegram, not to your VPS. The latency you control is your server’s leg to api.telegram.org plus the leg to any API your handlers call. The Bot API docs do not say where api.telegram.org is served from, so measure it from the server you plan to use, such as an hourly test server in our Istanbul location:
curl -s -o /dev/null -w 'connect %{time_connect}s tls %{time_appconnect}s total %{time_total}s\n' https://api.telegram.orgRepeat it a few times, do the same against your database or LLM provider, and compare the sum with your current host. On an hourly server, running this check for one hour costs $0.01. The VPS location guide covers the method in depth.
What does a Telegram bot VPS cost per month?
A bot that must answer at 3 a.m. runs 24/7, and that is where the monthly cap matters: the server is billed by the hour, but its charges stop at the plan’s monthly price in each billing period (one month from your order date), so a live bot is in effect a monthly VPS with no contract and no monthly prepayment. The same hourly billing keeps the work before launch cheap: build, test and measure for a few hours, then delete the server. The table shows Quartz Q1 at each duration, with the cap applied once it is reached.
| Duration | Hours on the meter | Cost $0.01 | Note |
|---|---|---|---|
| 1 hour | 1 | $0.01 | |
| 8 hours | 8 | $0.08 | |
| 1 day | 24 | $0.24 | |
| 7 days | 168 | $1.68 | |
| 30 days | 720 | $5.00 | Capped at the monthly price |
Billing rules: Billing is by the hour: every hour a server exists is charged at the plan’s hourly rate. The price tapes and cost tables on this site count every started hour as a full hour, so they show the most a duration can cost; the cost calculator charges a partial hour to the nearest cent, as the bill does. Every server is billed by the hour: the plan’s hourly rate is deducted from the server’s prepaid balance for every hour it exists, powered on or off, until you delete it. A stopped server is still billed, because its vCPU, memory, disk and IP addresses stay reserved for you; only deleting the server stops billing. Each new server is ordered with an initial credit, prepaid and used for that server’s hours, and account top-ups start at $5.
Traffic rarely matters for a text bot. Istanbul plans include the monthly traffic allowance listed for each plan, prorated for a server that exists for part of a billing period. A bot that sends large media should check its plan’s allowance first. See why capped hourly billing never costs more than monthly, price your own mix with the VPS cost calculator, compare every size on the pricing page, or check what a VPS costs across 20 providers.
What about free hosting?
Free platform tiers can host a hobby bot if you accept their limits. As of October 2026, Render’s documentation says it spins down a free web service after 15 minutes without inbound traffic and that spinning back up “takes about one minute”. Render offers free instances only for web services, static sites, Postgres and Key Value, so a background worker, the service type a polling bot would use, is not free.
So a webhook bot on a free web service answers the first message after an idle spell about a minute late. A free tier fits a demo bot few people use. A VPS fits a bot people rely on, long polling, or anything that keeps state on disk.
How to deploy a Telegram bot on a VPS (Ubuntu 24.04)
These steps assume a fresh Ubuntu 24.04 LTS server, a sudo user and SSH keys. If you are not there yet, connect to the VPS over SSH and work through the new-server security checklist first. Debian 12 works the same way: it ships Python 3.11 and systemd 252, which both meet the requirements below.
python-telegram-bot or aiogram?
Both main Python libraries are async and need Python 3.10 or newer; Ubuntu 24.04 ships Python 3.12.
| Library | Version (Oct 2026) | Bot API level | Webhook server | Python |
|---|---|---|---|---|
| python-telegram-bot | 22.8 (June 12, 2026) | 10.0 | Built in: run_ with the [webhooks] extra (Tornado) | 3.10+ |
| aiogram | 3.31.0 (August 26, 2026) | 10.3 | aiohttp integration (SimpleRequestHandler) | 3.10 to 3.14 |
This guide uses python-telegram-bot because run_polling() and run_webhook() handle the webhook bookkeeping for you: polling mode always calls deleteWebhook at startup, and webhook mode always calls setWebhook. The systemd and Caddy parts below work unchanged for aiogram or a Node.js bot; only ExecStart changes. Prefer containers? Set up Docker with the Docker install guide and pass the token as a Compose secret: Docker mounts it at /run/secrets/bot_token, so setting CREDENTIALS_DIRECTORY=/run/secrets lets the same bot.py read it.
1. Create the bot and a test twin
In Telegram, open @BotFather, send /newbot and choose a name and a username ending in bot. BotFather replies with the token, which “can be used by anyone to control your bot”, in Telegram’s words. Create a second bot for testing too, as Telegram’s bot features page suggests: your laptop polls the test bot while the VPS polls the real one, with no 409 conflicts.
2. Create a service user and a virtual environment
Install the venv module, which Ubuntu packages separately:
sudo apt update && sudo apt install -y python3-venvCreate a system user with no login shell. The bot runs as this user, never as root:
sudo useradd --system --create-home --home-dir /opt/tgbot --shell /usr/sbin/nologin tgbotCreate the virtual environment and install the library, pinned to the version this guide was checked against:
sudo -H -u tgbot python3 -m venv /opt/tgbot/venv
sudo -H -u tgbot /opt/tgbot/venv/bin/pip install "python-telegram-bot[webhooks]==22.8"3. Store the token where only systemd can read it
Many tutorials put the token in a .env file and load it with EnvironmentFile=. The systemd manual advises against environment variables for secrets: variables set in a unit are visible to unprivileged clients over D-Bus, and every environment variable is inherited by child processes, including across security boundaries. It recommends LoadCredential= instead, which hands the service a read-only copy that only the service user and root can read. Ubuntu 24.04 ships systemd 255, and the setting has existed since version 247.
Create a root-only directory, then write the token without it touching your shell history. Paste the token, press Enter, then Ctrl+D:
sudo install -d -m 700 /etc/tgbot
sudo sh -c 'umask 077; cat > /etc/tgbot/bot_token'Warning: Never commit the token to Git or paste it into the unit file. If it leaks, send /token to @BotFather to generate a new one (the command named in Telegram’s BotFather guide), write it to /etc/tgbot/bot_token again and restart the service.
4. Write the bot
Create the file as root. The bot only needs to read its code, and ProtectSystem=strict in the unit below keeps /opt read-only for the running service:
sudo nano /opt/tgbot/bot.pyPaste this echo bot. It reads the token from the systemd credential directory, falls back to a BOT_TOKEN environment variable when you run it on your laptop, and picks its mode from BOT_MODE:
import logging
import os
from pathlib import Path
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)
# httpx logs every request URL at INFO, and Bot API URLs contain your token.
logging.getLogger("httpx").setLevel(logging.WARNING)
def read_secret(name: str) -> str:
"""Read a systemd credential; fall back to an env var for local runs."""
cred_dir = os.environ.get("CREDENTIALS_DIRECTORY")
if cred_dir and (Path(cred_dir) / name).is_file():
return (Path(cred_dir) / name).read_text().strip()
return os.environ[name.upper()]
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.reply_text("Hi! I run on a VPS. Send me any text.")
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.reply_text(update.effective_message.text)
def main() -> None:
app = Application.builder().token(read_secret("bot_token")).build()
app.add_handler(CommandHandler("start", start))
# UpdateType.MESSAGE skips edited messages, which have no update.message
app.add_handler(
MessageHandler(filters.UpdateType.MESSAGE & filters.TEXT & ~filters.COMMAND, echo)
)
if os.environ.get("BOT_MODE", "polling") == "webhook":
app.run_webhook(
listen="127.0.0.1",
port=8081,
url_path="telegram",
webhook_url=os.environ["WEBHOOK_URL"],
secret_token=read_secret("webhook_secret"),
allowed_updates=Update.ALL_TYPES,
)
else:
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()Three lines do more than they appear to:
logging.getLogger("httpx").setLevel(logging.WARNING): the library sends requests through httpx, which logs every request URL at INFO level. Bot API URLs look likehttps://api.telegram.org/bot<token>/METHOD, so without this line your token lands in the journal. The library’s own examples include it.filters.UpdateType.MESSAGE: aMessageHandleralso matches edited messages, whereupdate.messageisNone. This filter pluseffective_messageavoids an error every time someone edits a message.listen="127.0.0.1": in webhook mode the bot listens on localhost only. Caddy handles TLS on port 443, so the bot’s own port (8081) need not be one of Telegram’s four.
How to keep a Telegram bot running 24/7 with systemd
A systemd service starts the bot at boot, restarts it after a crash and sends its output to the journal; our guide to creating a systemd service explains each directive in more depth. Create the unit file:
sudo nano /etc/systemd/system/tgbot.service[Unit]
Description=Telegram bot (python-telegram-bot)
Wants=network-online.target
After=network-online.target
[Service]
User=tgbot
Group=tgbot
WorkingDirectory=/opt/tgbot
ExecStart=/opt/tgbot/venv/bin/python /opt/tgbot/bot.py
Environment=PYTHONUNBUFFERED=1
LoadCredential=bot_token:/etc/tgbot/bot_token
StateDirectory=tgbot
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.targetWhat each hardening line does, per the systemd.exec manual:
LoadCredential=bot_token:/etc/tgbot/bot_tokencopies the token into the directory named by$CREDENTIALS_DIRECTORY, readable only by the tgbot user and root.ProtectSystem=strictmounts the whole file system read-only for the bot, so it cannot modify its own code or the OS.StateDirectory=tgbotcreates/var/lib/tgbot, owned by tgbot and writable despiteProtectSystem=strict. Keep SQLite files and other state there.ProtectHome=yes,PrivateTmp=yesandNoNewPrivileges=yeshide /home, give the bot a private /tmp and block privilege escalation.Restart=on-failurewithRestartSec=5restarts the bot 5 seconds after a crash, but not after a clean stop.
Load the unit, start it now and enable it at boot:
sudo systemctl daemon-reload
sudo systemctl enable --now tgbotFollow the log while you message the bot from Telegram:
sudo journalctl -u tgbot -fYou should see Application started and no lines that contain your token. Then prove it survives a reboot: run sudo reboot, reconnect, and check that systemctl is-active tgbot prints active.
Update and redeploy the Telegram bot
Edit the code, restart the service and read the newest log lines:
sudo nano /opt/tgbot/bot.py
sudo systemctl restart tgbot
sudo journalctl -u tgbot -n 30 --no-pagerA restart loses no messages: Telegram stores incoming updates until the bot receives them, for up to 24 hours. To upgrade python-telegram-bot, read its changelog, run the same pip install command with the new version number and restart. Pinning means upgrades happen when you choose, not on the next rebuild.
Switch to a webhook behind Caddy
Polling is complete as it stands. Switch to a webhook only if one of the reasons above applies. Caddy fits Telegram’s requirements by default: it obtains and renews certificates from a public ACME CA such as Let’s Encrypt automatically, and its default minimum protocol is TLS 1.2. Install it from Caddy’s official apt repository as shown in the Caddy reverse proxy guide, then work through five steps.
1. DNS. Create an A record such as bot.example.com that points to the server’s IPv4 address. Telegram does not deliver webhooks over IPv6.
2. Firewall. Caddy needs ports 80 and 443 open for certificate challenges and the HTTP redirect, and Telegram posts to 443. If UFW is active:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp3. Caddyfile. Add this site block to /etc/caddy/Caddyfile (on a fresh install, replace the default contents). Only the webhook path reaches the bot; every other path on that hostname gets a 404:
bot.example.com {
handle /telegram {
reverse_proxy 127.0.0.1:8081
}
handle {
respond 404
}
}sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy4. Secret. Hex output uses only characters secret_token allows, and 64 characters is well under the 256 limit:
sudo sh -c 'umask 077; openssl rand -hex 32 > /etc/tgbot/webhook_secret'5. Mode. Open a drop-in for the service, add these lines with your hostname and save. systemctl edit reloads systemd when the editor closes:
sudo systemctl edit tgbot[Service]
Environment=BOT_MODE=webhook
Environment=WEBHOOK_URL=https://bot.example.com/telegram
LoadCredential=webhook_secret:/etc/tgbot/webhook_secretConfirm that systemd sees the override, then restart. systemctl cat tgbot must print the drop-in file with your three lines:
systemctl cat tgbot
sudo systemctl restart tgbotCheck it from the outside first. A JSON POST without the secret header must get 403, and any other path must get 404. That proves the TLS path, the Caddy route and the secret check. Keep the JSON header: python-telegram-bot also answers 403 to any request without Content-Type: application/json, so a bare POST would pass even with no secret set.
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{}' https://bot.example.com/telegram
curl -s -o /dev/null -w '%{http_code}\n' https://bot.example.com/Then ask Telegram what it sees. This reads the token from the root-only file and passes it to curl on standard input, so it never appears in your shell history or the process list:
sudo sh -c 'printf "url = \"https://api.telegram.org/bot%s/getWebhookInfo\"\n" "$(cat /etc/tgbot/bot_token)"' | curl -s -K - | python3 -m json.toolA healthy webhook shows your URL, a pending_update_count near 0 and no last_error_message. To go back to polling, run sudo systemctl revert tgbot (it removes the drop-in and keeps your unit), then sudo systemctl daemon-reload and restart the service: polling mode deletes the webhook on startup.
Troubleshooting: the errors you will actually see
| Symptom | Likely cause | Fix |
|---|---|---|
409 Conflict: terminated by other getUpdates request; make sure that only one bot instance is running | Two processes poll with the same token, often a laptop copy plus the VPS | Stop the other copy. Use a separate test bot for local work. |
409 Conflict: can't use getUpdates method while webhook is active | A webhook is still set; getUpdates does not work while one exists | Call deleteWebhook; python-telegram-bot’s polling mode does this at startup. |
pending_ keeps growing | Telegram cannot reach or verify your endpoint | Read last_, check the A record, ports 80 and 443, and sudo journalctl -u caddy. |
Certificate or SSL error in last_ | CN or SAN mismatch, wildcard certificate or incomplete chain | Let Caddy issue a certificate for the exact hostname you registered. |
| Every webhook request gets 403 | The webhook was registered again without this secret, for example by a manual setWebhook call or another copy of the bot | Stop the other copy, then restart the bot so run_ registers the current secret. |
| Bot ignores most group messages | Privacy mode, on by default | In privacy mode a bot sees only commands meant for it and replies to its messages. Turn it off with /setprivacy in @BotFather and re-add the bot to the group, or make the bot an admin. |
| 429 Too Many Requests | Flood limits: about 1 msg/s per chat, 20 msg/min per group, ~30 msg/s overall | Wait for the retry_ seconds in the response; spread broadcasts out. |
| Bot answers old messages after downtime | Telegram queued up to 24 hours of updates | Pass drop_ to run_ or run_ if stale updates should be skipped. |
Service restarts every 5 seconds; the journal shows InvalidToken and “was rejected by the server” | Wrong, truncated or revoked token | Rewrite /etc/ and run sudo systemctl restart tgbot. |
Go-live checklist
- SSH keys only, root login off and a firewall that opens nothing beyond SSH (plus 80 and 443 for a webhook).
- Token in
/etc/tgbot/bot_tokenwith mode 600, loaded withLoadCredential=; never in Git, the unit file or the environment. - The httpx logger at WARNING, and
sudo sh -c 'journalctl -u tgbot | grep -c -F -f /etc/tgbot/bot_token'prints0. - Exactly one process per token, with a separate bot for testing.
- Commands that change data or reveal anything private check
update.effective_user.idagainst your own user ID: anyone who finds the bot’s username can message it. systemctl is-enabled tgbotprintsenabled, and the bot came back after a reboot.- For a webhook:
getWebhookInfoshows nolast_error_message. - State kept in
/var/lib/tgbotand covered by a snapshot or backup. - A broadcast plan that respects Telegram’s limits and the acceptable use policy: no unsolicited bulk messages.
Deploy this setup
Run this Telegram bot 24/7, with room for Caddy and SQLite
Quartz Q2 · 1 shared vCPU · 2 GB RAM · 50 GB NVMe · Istanbul
- Per hour$0.02/hour
- Per day (24 h)$0.48/day
- Monthly cap$10.00/monthFor this job
Starts with a $5 initial credit, which goes into the server’s balance and pays for its hours.
Billed by the hour, never more than $10.00 per billing period. Delete the server and billing stops.



