Telegram bots

How to host a Telegram bot on a VPS: from BotFather to always online

Hosting a Telegram bot on a VPS: a dark terminal card showing the steps, from getting a token from BotFather and writing the bot in Python to a systemd service for 24/7 uptime and choosing between polling and a webhook on a Linux server

Hosting a Telegram bot on a VPS means turning a Python script that only works while your terminal is open into a service that stays online around the clock and comes back by itself after every crash and every reboot. A bot running on your personal laptop goes offline with the first power cut, and a user who messages it twice without an answer won't try a third time. This guide covers the whole path: the token from BotFather, a bot in Python, a systemd unit, the choice between polling and a webhook, and the limits Telegram itself imposes, which you don't notice until you hit them.

Why host a Telegram bot on a VPS?

A Telegram bot is an always-on process: it has to stay connected to Telegram's servers to receive messages and answer them. That one sentence settles the infrastructure question:

  • A personal computer won't do: it gets switched off, Windows restarts for updates, and a home connection has no fixed, stable IP address.
  • Shared hosting won't do either: when people search for "Telegram bot hosting", what they actually need is a small VPS. Shared hosting is built for PHP websites and doesn't let you run a permanent Python process; our comparison of shared hosting and a VPS draws exactly that line.
  • A VPS is exactly what you need: root access, a machine that is always on, and systemd to start the bot again automatically after every crash or reboot.

A Telegram bot is one of the lightest workloads there is and is happy on the smallest plan; we do the exact math further down. But don't confuse "always online" with "the server is up": the server can be running while the bot process dies from an unhandled exception, a brief network outage or the OOM killer. The core of this guide is about that layer.

Step 1: get a token from BotFather and test it

Every bot's identity is a "token" that you get from Telegram's official parent bot:

  1. In Telegram, search for @BotFather and open it (it should carry the blue verification check).
  2. Send the /newbot command.
  3. Choose a display name (for example, "My Shop Bot").
  4. Choose a unique username that ends in bot (for example, MyShopHelperBot).

BotFather gives you a token that looks like 123456789:AAE...xyz; this string is the complete key to controlling your bot. Before doing anything else, check that it works with getMe, the method Telegram's documentation describes as "a simple method for testing your bot's authentication token":

curl -sS "https://api.telegram.org/bot123456789:AAE...xyz/getMe"
{"ok":true,"result":{"id":123456789,"is_bot":true,"first_name":"Shop Helper",
"username":"MyShopHelperBot","can_join_groups":true,
"can_read_all_group_messages":false,"supports_inline_queries":false}}

If the token is wrong or has been revoked, the answer is short and unambiguous:

{"ok":false,"error_code":401,"description":"Unauthorized"}

Learn two more BotFather commands as well: /setcommands registers your bot's command list in Telegram's menu (its programmatic equivalent is setMyCommands), and /setprivacy decides which messages the bot sees in groups.

⚠️ Guard the token like a password: never put it in public code, on GitHub or in a screenshot. If it leaks, Telegram's documentation is explicit: "If your existing token is compromised or you lost it for some reason, use the /token command to generate a new one." The old token stops working immediately, so update the new value on the server as well.

Preparing the server: Python, a virtual environment and a user without a shell

Connect to the server over SSH and lay out the environment cleanly. From version 22 onward, the python-telegram-bot library needs at least Python 3.10; Ubuntu 24.04 ships 3.12 and Debian 12 ships 3.11, so both clear that bar.

apt update && apt install -y python3-venv
mkdir -p /opt/echobot && cd /opt/echobot
python3 -m venv venv
venv/bin/pip install "python-telegram-bot[job-queue,rate-limiter]==22.*"

Why a venv instead of a direct install? Because since Debian 12 and Ubuntu 24.04 the system Python is marked as "externally managed", and pip install outside a virtual environment is refused with error: externally-managed-environment. That behavior is deliberate and comes from PEP 668, which stops pip from overwriting packages the operating system itself relies on. The right approach is the one the PEP recommends: create a virtual environment and use path/to/venv/bin/pip. Stay away from the --break-system-packages flag; its name describes exactly what it does.

The two extras above are optional, but you will almost always end up needing them: job-queue makes scheduled jobs possible inside the bot, and rate-limiter enables the AIORateLimiter class we come back to in the section on limits. Now create a user for the bot to run as, deliberately without a shell:

useradd -r -s /usr/sbin/nologin botuser
chown -R botuser: /opt/echobot

Building a Telegram bot in Python: an echo bot in twenty lines

For building a Telegram bot in Python, the python-telegram-bot library is the de facto standard: mature, well documented and fully async. Create the file /opt/echobot/bot.py:

import logging, os
from telegram import Update
from telegram.ext import (Application, CommandHandler,
                          MessageHandler, filters, ContextTypes)

logging.basicConfig(format="%(asctime)s %(name)s %(levelname)s %(message)s",
                    level=logging.INFO)

TOKEN = os.environ["BOT_TOKEN"]

async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text("Hi! Send me anything and I'll send it right back.")

async def echo(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(update.message.text)

app = Application.builder().token(TOKEN).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
app.run_polling(allowed_updates=["message"])

Three decisions here are deliberate. First, the token is read from an environment variable, not written into the code. Second, logging.basicConfig sends the library's output to stdout, and systemd stores it in the journal; without it, you have no clue to go on when the bot stops working. Third, allowed_updates asks for message updates only; if you leave it out, Telegram sends every update type except chat_member and reaction events.

Keep the token in a separate file

Create the file /etc/echobot.env and lock down its permissions. systemd reads this file as root, before it switches to the service user, so botuser doesn't need to be able to open it:

printf 'BOT_TOKEN=123456789:AAE...xyz\n' > /etc/echobot.env
chown root:root /etc/echobot.env
chmod 600 /etc/echobot.env

cd /opt/echobot
BOT_TOKEN=123456789:AAE...xyz venv/bin/python bot.py

Send /start to your bot: you should get a reply, and any text you send should come straight back. These twenty lines are the skeleton of every more serious bot; from here you just add handlers.

Always online with systemd

Running the bot by hand with python bot.py only lasts as long as your SSH terminal stays open. The professional solution is to define the bot as a systemd service. Create the file /etc/systemd/system/echobot.service:

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

[Service]
Type=exec
User=botuser
WorkingDirectory=/opt/echobot
EnvironmentFile=/etc/echobot.env
ExecStart=/opt/echobot/venv/bin/python /opt/echobot/bot.py
Restart=always
RestartSec=5
StateDirectory=echobot
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes

[Install]
WantedBy=multi-user.target

The Restart=always line is the heart of it: if the bot crashes, systemd starts it again five seconds later. The systemd documentation calls on-failure "the recommended choice for long-running services", and the difference that matters here is a clean exit: with always, even an exit with code zero triggers a restart. For a bot that should never finish on its own, always makes sense.

Type=exec tells systemd to count the service as "started" only once the program has actually been executed, not immediately after the fork, so an error such as "file not found" shows up right there. The lines after it are hardening: NoNewPrivileges blocks privilege escalation through setuid files, PrivateTmp gives the service its own private /tmp, ProtectSystem=strict makes the whole file system read-only except for /dev, /proc and /sys, ProtectHome hides users' home directories, and StateDirectory=echobot creates a writable path under /var/lib for this service alone. That is the right place for the bot's SQLite database, not next to the code.

systemctl daemon-reload
systemctl enable --now echobot
systemctl status echobot

● echobot.service - Telegram Echo Bot
     Loaded: loaded (/etc/systemd/system/echobot.service; enabled; preset: enabled)
     Active: active (running) since Mon 2026-09-07 11:04:18 UTC; 12min ago
   Main PID: 1841 (python)
      Tasks: 4 (limit: 2296)
     Memory: 71.4M (peak: 78.2M)
     CGroup: /system.slice/echobot.service
             └─1841 /opt/echobot/venv/bin/python /opt/echobot/bot.py

From this moment on, the Telegram bot on your VPS is a real service, with a side benefit: systemctl stop sends SIGTERM, and by default run_polling catches that signal (along with SIGINT and SIGABRT) and shuts the bot down cleanly, without abandoning a message halfway through processing.

💡 Tip: We deliberately run the service as the restricted botuser, not as root: if the bot's code has a vulnerability, the damage stays contained. One harmless side effect of ProtectSystem=strict is that Python can no longer write the __pycache__ folder next to the code; the program runs without errors and only loses one layer of bytecode caching. Take care of the remaining basics with our Linux server security checklist.

When the service won't start: journalctl and the start-rate trap

Follow the live log with journalctl -u echobot -f, and look up past errors with journalctl -u echobot --since "1 hour ago" -p err. The most common crime scene looks like this:

journalctl -u echobot -n 6 --no-pager
Sep 07 12:02:39 vps python[2107]: telegram.error.InvalidToken: Invalid token
Sep 07 12:02:39 vps systemd[1]: echobot.service: Main process exited, code=exited, status=1/FAILURE
Sep 07 12:02:39 vps systemd[1]: echobot.service: Failed with result 'exit-code'.
Sep 07 12:02:44 vps systemd[1]: echobot.service: Scheduled restart job, restart counter is at 3.
Sep 07 12:03:04 vps systemd[1]: echobot.service: Start request repeated too quickly.
Sep 07 12:03:04 vps systemd[1]: Failed to start echobot.service - Telegram Echo Bot.

The last two lines are the trap that confuses so many people. Besides Restart=, systemd also has a start-rate limiter: if a unit is started more than StartLimitBurst times within StartLimitIntervalSec, it is not allowed to start again and its state becomes failed (Result: start-limit-hit). Check the defaults on the server itself:

systemctl show -p DefaultStartLimitBurst -p DefaultStartLimitIntervalUSec
DefaultStartLimitIntervalUSec=10s
DefaultStartLimitBurst=5

That is five starts in ten seconds, and now it is clear why RestartSec=5 isn't decoration: the default RestartSec is only 100 milliseconds, so a bot that dies instantly because of a bad token hits the limit in under a second and the service gets locked in the failed state. Once you have fixed the problem, release the locked service with systemctl reset-failed echobot. If the crashes are sporadic, use RestartSteps and RestartMaxDelaySec instead of raising the limit, so the delay between attempts grows exponentially.

Polling or webhook?

A bot can receive new messages in two ways, and Telegram's documentation explicitly calls them "two mutually exclusive ways". The code above uses polling: the bot keeps asking "any new messages?". A webhook works the other way round: Telegram pushes each message straight to your HTTPS address.

CriterionPollingWebhook
Setup complexityNone: just the code aboveDomain + TLS certificate + port
Needs a public IP or domainNoYes (Telegram has to reach your server)
Allowed portsNo restriction (outbound HTTPS)Only 443, 80, 88 and 8443
CertificateNot neededValid or self-signed, TLS 1.2 or higher
Response latencyUsually under a secondAlmost instant
Several instances at onceNot allowed: error 409No problem
Best forMost bots, getting startedVery high traffic, several bots on one server

Our honest advice: start with polling. It stays good enough for most bots and has two fewer parts that can break. One point applies to both methods: updates wait on Telegram's servers until the bot collects them, but according to the documentation they "will not be kept longer than 24 hours". If the bot stays down for a full day, the messages from that window are lost for good.

Setting up a webhook: domain, certificate and secret token

The order of work is: create an A record that points a subdomain to the server's IP, set up Nginx on Ubuntu and get a free certificate with Certbot. Choose a URL path nobody can guess, and always set a secret_token:

curl -sS -X POST "https://api.telegram.org/bot$BOT_TOKEN/setWebhook" \
  -d "url=https://bot.example.ir/tg/9f3c1a" \
  -d "secret_token=$WEBHOOK_SECRET" \
  -d "max_connections=20" \
  -d 'allowed_updates=["message","callback_query"]'
{"ok":true,"result":true,"description":"Webhook was set"}

Telegram sends the secret_token value in the X-Telegram-Bot-Api-Secret-Token header of every request, and your code must throw away any request that arrives without it. It can be 1 to 256 characters long, and only the letters A–Z and a–z, digits, hyphens and underscores are allowed. max_connections defaults to 40, with a range of 1 to 100. Always ask Telegram itself about the webhook's health, not the Nginx log:

curl -sS "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"
{"ok":true,"result":{"url":"https://bot.example.ir/tg/9f3c1a",
"has_custom_certificate":false,"pending_update_count":137,
"last_error_date":1757246400,
"last_error_message":"Wrong response from the webhook: 502 Bad Gateway",
"max_connections":20,"ip_address":"203.0.113.10",
"allowed_updates":["message","callback_query"]}}

This output tells you everything: 137 updates are waiting in the queue, and the last error was a 502, which means Nginx is up but the process behind it isn't answering. To go back to polling, you have to delete the webhook; as long as it is active, the getUpdates method doesn't work:

curl -sS "https://api.telegram.org/bot$BOT_TOKEN/deleteWebhook?drop_pending_updates=true"
{"ok":true,"result":true}
⚠️ If you run a firewall, note that Telegram sends webhook requests only from two ranges: 149.154.160.0/20 and 91.108.4.0/22. Restricting inbound traffic to those two ranges is the right move, but write it down somewhere. Otherwise, one day the bot will silently stop receiving messages and the Nginx log won't show a thing, because no request ever arrived.

The limits Telegram imposes

All of these limits come from Telegram's official documentation, and none of them moves when you upgrade the server:

  • In a single chat, don't send more than one message per second.
  • In a group, a bot can't send more than 20 messages per minute.
  • For bulk notifications, the approximate limit is 30 messages per second; Telegram suggests spreading broadcasts over 8–12 hours.
  • Downloading files with the getFile method works up to 20 MB.
  • Sending files by direct upload is allowed up to 50 MB, and photos up to 10 MB.

When you go over a limit, Telegram answers with code 429 and tells you in the retry_after field how many seconds to wait. You don't have to write that logic by hand: with the rate-limiter extra, the AIORateLimiter class respects both the per-chat limit and the bot's overall limit, and on a RetryAfter error it holds back all requests for the announced time:

from telegram.ext import AIORateLimiter

app = (Application.builder().token(TOKEN)
       .rate_limiter(AIORateLimiter(max_retries=3))
       .build())

If you need bigger files, the only official route is to run a "local Bot API server"; according to the documentation, downloads then have no size limit and uploads go up to 2000 MB, at the cost of maintaining a separate service.

Why can't my bot see group messages?

The answer is a feature, not a bug: every bot is added to groups with Privacy Mode on by default and sees only three kinds of messages: commands explicitly meant for it (/command@this_bot), general commands such as /start if it was the last bot to send a message to the group, and replies to its own messages. The one exception is a bot that is a group admin; admins always see every message. To switch this mode off, go to /setprivacy in BotFather and then, and this is the important part, remove the bot from the group and add it again, or the change won't take effect. You can read the current state from the can_read_all_group_messages field in the getMe output: true means privacy is off.

Networking and choosing a server for your Telegram bot

One technical fact to know before you deploy: to work at all, the bot has to reach api.telegram.org from the server itself. Network paths differ from one network to another, and in Iran this is anything but theoretical: Telegram has been blocked in the country since 2018, and from a server in an Iranian datacenter the API may well not answer at all. So don't guess; test from the server itself:

curl -sS -m 10 -o /dev/null -w "%{http_code}\n" https://api.telegram.org
302

If you get an HTTP code back (this root address normally returns 302), the path is open. If the request times out, this server has no direct access, and you need to settle where the bot will run before doing anything else; for a bot that has to talk to Telegram, that means a server outside Iran (see below). This is where hourly billing pays off: you create a server, run a two-minute network test and delete it if the plan doesn't work. One important point about that billing: powering the server off doesn't stop the meter. A powered-off machine still pays a percentage of the hourly rate (half by default), because its RAM and disk are still reserved. The only thing that brings the cost to zero is deleting the server, and if your wallet balance reaches zero, active servers are suspended automatically. The details are in our hourly cloud server guide.

How many resources do you need?

When choosing a server for a Telegram bot, the biggest mistake is buying big. A typical python-telegram-bot bot uses about 60 to 120 MB of RAM, and its CPU sits close to zero most of the time:

  • A personal bot or a small shop: the smallest plan (1 core / 1 GB RAM) is more than enough.
  • A bot with a database and a few thousand users: 2 GB of RAM is a comfortable choice, and this is where NVMe storage shows in query speed.
  • Disk: the whole project, venv included, usually stays under 200 MB; disk space is almost never a bot's bottleneck.

That is why a small server can easily run several bots side by side, as long as each one has its own systemd unit and its own user. Mehran Host's cloud servers are in a datacenter inside Iran; if your bot needs to reach destinations outside the country, Telegram's API included, international servers in Hetzner datacenters are available, billed in Toman (Iran's everyday currency unit).

If you go with a server outside Iran, pick the location by numbers, not guesswork: the Telegram Bot API was about 15 ms away from our server in Falkenstein, Germany, and the ping from inside Iran to all six Hetzner locations, which matters when you manage the bot over SSH, is in our comparison of ping from Iran to servers in Germany, Finland, the US and Singapore.

Common Telegram bot errors and how to fix them

Almost every problem after deployment fits into one of these six rows:

SymptomLikely causeNext step
The log is full of Conflict: terminated by other getUpdates requestTwo instances of the bot are polling with the same token at the same timeStop the manual run: the service or the terminal, never both
The bot receives no messages, but the service is activeA webhook is still set, so getUpdates doesn't workCheck the getWebhookInfo output and run deleteWebhook if needed
401 Unauthorized within the first secondThe token is wrong, revoked, or was copied with an extra spaceVerify the value in /etc/echobot.env with getMe
The service is stuck in failed (start-limit-hit)Back-to-back crashes hit the start-rate limitRead the cause in the journal, then run systemctl reset-failed
Messages arrive minutes late, or not at all429 errors and a backed-up queueEnable AIORateLimiter and lower your sending rate
The bot works in private chats but not in groupsPrivacy Mode is onTurn it off with /setprivacy and add the bot to the group again

After going live: monitoring, updates and backups

Three habits keep a bot alive. The first is monitoring: check the service's memory usage with systemctl status or systemd-cgtop, and if the number climbs day after day, look for a memory leak; we have collected the broader tools in our Linux server monitoring guide. A memory cap with MemoryMax=512M in the unit is also wise: instead of a leak taking down the whole server, only that service gets killed and restarted.

The second is updates: pin the library version and upgrade on purpose, inside the same virtual environment, then restart and watch the log for a few minutes:

/opt/echobot/venv/bin/pip install -U "python-telegram-bot[job-queue,rate-limiter]==22.*"
systemctl restart echobot
journalctl -u echobot -f

The third is backups: the bot's code usually lives in Git, but the database in /var/lib/echobot and the token file in /etc have no second copy. A daily snapshot of those two paths is the difference between "half an hour of work" and "rebuilding everything from scratch". You now have the full map: create a small server and practice the whole path with hourly billing.

Frequently asked questions

What resources does a Telegram bot need on a VPS?

The smallest VPS, with 1 CPU core and 1 GB of RAM, is enough for most bots, because a typical Python bot uses about 60 to 120 MB of memory and its CPU is idle most of the time. The whole project, virtual environment included, usually needs under 200 MB of disk. If the bot has a database and a few thousand active users, 2 GB of RAM is more comfortable. What really matters is the server's stable access to the Telegram API, not the size of the machine.

How do I keep a Telegram bot online 24/7?

Define the bot as a systemd service so the operating system keeps it alive for you. Create a unit file with Restart=always and RestartSec=5 and enable it with systemctl enable --now; from then on, the bot comes back automatically after both crashes and reboots. The five-second delay matters, because by default systemd refuses more than five starts in ten seconds and locks the service in the failed state.

What is the difference between polling and a webhook for a Telegram bot?

With polling, the bot keeps asking Telegram whether there are new messages; with a webhook, Telegram sends each message to your HTTPS address. Polling needs no domain, certificate or open port and is the simplest way to start. A webhook has lower latency but requires a domain, a certificate with TLS 1.2 or higher, and one of the ports 443, 80, 88 or 8443. The two are mutually exclusive: while a webhook is active, getUpdates doesn't work.

What should I do if my Telegram bot token leaks?

Send the /token command to BotFather right away to generate a new token; the old one stops working at that moment, cutting off anyone who had it. Then replace the value on your server and restart the service, or the bot will fail to start with a 401 error. To keep it from happening again, store the token in a separate file with restricted permissions, so it never ends up in a Git repository.

Why doesn't my bot receive messages in a group?

Because every bot joins groups with Privacy Mode on by default, and in that mode it only sees commands sent directly to it and replies to its own messages. To see every message, either make the bot a group admin, or switch the mode off with /setprivacy in BotFather and then remove the bot from the group and add it again. The current state shows in the can_read_all_group_messages field of the getMe output.

Related tutorials

Ready for some hands-on practice?

A Mehran Host hourly cloud server is ready in under 60 seconds. Practice as much as you like and pay only for the hours you use. Estimate the cost with the pricing calculator before you sign up.