How to deploy a Telegram bot on a VPS: systemd, webhooks and nginx

How to deploy a Telegram bot on a VPS: polling or webhook, systemd or Docker, nginx with Let's Encrypt, Telegram rate limits, and updates without losing messages.

This guide takes a bot that already works on your machine and moves it to a server. The examples use Python and aiogram 3, with the differences for python-telegram-bot and grammY noted along the way. Current as of September 2026: Bot API 10.3, aiogram 3.31, python-telegram-bot 22.8, grammY 1.46, uv 0.12.

What you'll need

Important: the server needs unrestricted access to Telegram. Russia, for example, has blocked Telegram since March 2026: from Russian hosting, Bot API requests hang, and for some people incoming webhooks never arrive. The lead developer of telegram-bot-api advises running the bot itself where Telegram is freely reachable.

Step 1. Choose polling or a webhook

Telegram hands updates (messages, button presses) to a bot in one of two ways, and only one works at a time: while a webhook is set, getUpdates fails with error 409. Telegram keeps updates the bot hasn't fetched for up to 24 hours, so a short outage loses nothing.

  • Long polling (getUpdates). The bot keeps a request to api.telegram.org open, and Telegram answers as soon as an update arrives, so latency is the same as with a webhook. No domain, certificate or open ports needed. The catch: only one process can fetch updates. Each new request cuts off the previous one, so two copies of a bot split the updates between them and keep hitting 409 Conflict.
  • Webhook (setWebhook). Telegram pushes every update to your HTTPS address itself, over up to 40 parallel connections by default (max_connections, maximum 100). You need a domain, a certificate and port 443, 80, 88 or 8443. In return, you can run several copies of the bot behind a proxy, and one nginx can serve dozens of bots on different paths.

For one bot on one server, go with long polling: it's simpler and no slower. A webhook pays off when you need several instances and zero-pause deploys, when you run many bots, or when the bot already has a web part such as a Mini App that needs HTTPS anyway.

Step 2. Prepare the server

The bot runs as a system user without a shell or sudo. Its home directory goes under /srv, because ProtectHome from step 3 hides /home.

sudo useradd --system --create-home --home-dir /srv/mybot --shell /usr/sbin/nologin mybot
sudo apt install -y git sqlite3

Python with uv

uv installs the Python version your code needs without touching the system one: Ubuntu 24.04 ships 3.12, Debian 12 ships 3.11, and your code may want something else.

curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
sudo -u mybot -H git clone https://github.com/you/mybot.git /srv/mybot/app
sudo -u mybot -H uv sync --locked --no-dev --directory /srv/mybot/app

--locked installs exactly what uv.lock says and fails if the lock file no longer matches pyproject.toml. uv downloads the interpreter by itself into /srv/mybot/.local/share/uv/python and creates the environment in /srv/mybot/app/.venv.

Node.js

For a grammY bot, install Node.js 24 LTS following "Node.js apps with PM2 and Nginx" and run npm ci --omit=dev in the bot's directory. PM2 only works in fork mode with a single instance: in cluster mode every copy starts its own polling. PM2 also gives a process just 1.6 seconds to stop (kill_timeout), which is why the rest of this guide uses systemd.

Token and secrets

sudo install -d -m 700 /etc/mybot
sudo nano /etc/mybot/env
sudo chmod 600 /etc/mybot/env
BOT_TOKEN=1234567890:AAH-replace-with-yours
WEBHOOK_SECRET=output-of-openssl-rand-hex-32

systemd reads this file as root before starting the bot, so it can stay owned by root with mode 600: the process gets the variables but can't read the file. The format is strict: KEY=value, no export (systemd skips such a line) and no trailing comments (they end up in the value). If your code reads .env itself through python-dotenv, give the file to the mybot user instead.

Important: if the token leaks into git, logs or a chat, issue a new one in @BotFather with /token, and the old one stops working.

Step 3. Run the bot with systemd

Create /etc/systemd/system/mybot.service:

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

[Service]
User=mybot
Group=mybot
WorkingDirectory=/srv/mybot/app
EnvironmentFile=/etc/mybot/env
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/mybot/app/.venv/bin/python -m bot
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/mybot/data

[Install]
WantedBy=multi-user.target
sudo -u mybot mkdir -p /srv/mybot/data
sudo systemctl daemon-reload
sudo systemctl enable --now mybot
journalctl -u mybot -f

What matters here:

  • Wants= together with After=network-online.target. Without Wants=, the target may not be part of the boot at all. If the bot starts before the network is up, aiogram and python-telegram-bot exit right away, because their first getMe call has no retries. grammY waits for the network on its own.
  • RestartSec=5. By default systemd restarts a service after 100 ms and gives up after 5 starts within 10 seconds: the unit stays failed, and the bot is silent until someone starts it by hand. With a 5-second pause, that limit is never reached. On systemd 254 and newer (Ubuntu 24.04 and Debian 13, not Debian 12) you can grow the pause with RestartSteps=5 and RestartMaxDelaySec=60.
  • .venv/bin/python directly: uv run checks the environment against the lock file on every start and stays around as an extra process between systemd and the bot.
  • PYTHONUNBUFFERED=1. Without a terminal, Python block-buffers stdout, and print() output reaches the journal late. logging writes to stderr, which is line-buffered.
  • ProtectSystem=strict leaves only ReadWritePaths writable, so keep the SQLite database there.

For Node.js, set ExecStart to /usr/bin/node /srv/mybot/app/bot.js and replace PYTHONUNBUFFERED with NODE_ENV=production. Test autostart: sudo reboot, then systemctl is-active mybot and journalctl -u mybot -b.

Docker alternative

If the server already runs Docker (see "Docker and Docker Compose"), build an image with uv:

FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:0.12.21 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY . .
RUN uv sync --locked --no-dev
CMD [".venv/bin/python", "-m", "bot"]
services:
  bot:
    build: .
    restart: unless-stopped
    env_file: /etc/mybot/env
    stop_grace_period: 60s
    volumes:
      - ./data:/app/data
    logging:
      driver: local
  • CMD in JSON form. CMD python -m bot runs the bot through /bin/sh, which doesn't pass SIGTERM on, so after stop_grace_period (10 seconds by default) Docker kills the process.
  • Log rotation. The default json-file driver doesn't rotate logs, and over time the bot fills the disk. local keeps up to 100 MB per container.
  • One copy. docker compose up --scale bot=2 for a polling bot gives you 409 Conflict.

Step 4. Set up the webhook: domain, certificate, nginx

The ForestsNet Cloud rule: a webhook only on your own domain with your own certificate. Someone else's domain in a certificate is prohibited by our Terms of Service, and certificates on public ports are checked automatically. Telegram also accepts a self-signed certificate, but you'd have to upload it with setWebhook every time it changes. Let's Encrypt is free and renews itself.

Telegram's requirements: HTTPS with the full certificate chain, TLS 1.2 or newer, port 443, 80, 88 or 8443, IPv4 only, no redirects. Requests come from 149.154.160.0/20 and 91.108.4.0/22, but these ranges may change, so protect the endpoint with a secret rather than an address filter.

Install nginx and get a certificate ("Nginx: install and your first site", "Free SSL with Let's Encrypt (certbot)"), then add this to the bot domain's server block:

location = /tg/webhook {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

The bot listens on 127.0.0.1:8080 only, and just ports 80 and 443 are open to the outside: "The ufw firewall: managing ports". ssl_certificate must point to fullchain.pem, not cert.pem: without the intermediate certificate a browser still opens the site, but Telegram won't connect.

Caddy gets the certificate on its own, see "Caddy: automatic HTTPS". The handle blocks are required: without them respond runs before reverse_proxy.

bot.example.com {
    handle /tg/webhook {
        reverse_proxy 127.0.0.1:8080
    }
    handle {
        respond 404
    }
}

secret_token and the bot code

secret_token is a string of up to 256 characters (Latin letters, digits, _ and -) that Telegram puts into the X-Telegram-Bot-Api-Secret-Token header of every request. The bot rejects requests without it, so knowing the URL isn't enough to forge an update. Generate it with openssl rand -hex 32: Telegram rejects base64 with +, / and =. A webhook in aiogram 3, following the official example:

import os
from aiohttp import web
from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application

WEBHOOK_PATH = "/tg/webhook"
WEBHOOK_URL = "https://bot.example.com" + WEBHOOK_PATH
SECRET = os.environ["WEBHOOK_SECRET"]

dp = Dispatcher()  # add your routers: dp.include_router(...)

async def on_startup(bot: Bot) -> None:
    await bot.set_webhook(WEBHOOK_URL, secret_token=SECRET,
                          allowed_updates=dp.resolve_used_update_types())

def main() -> None:
    bot = Bot(os.environ["BOT_TOKEN"])
    dp.startup.register(on_startup)
    app = web.Application()
    SimpleRequestHandler(dispatcher=dp, bot=bot, secret_token=SECRET).register(app, path=WEBHOOK_PATH)
    setup_application(app, dp, bot=bot)
    web.run_app(app, host="127.0.0.1", port=8080)

if __name__ == "__main__":
    main()

In python-telegram-bot the same is application.run_webhook(listen="127.0.0.1", port=8080, url_path="tg/webhook", webhook_url=..., secret_token=...) from the python-telegram-bot[webhooks] package. In grammY it's webhookCallback(bot, "express", { secretToken }), which waits 10 seconds for your handler and then responds with an error.

Check the webhook without typing the token on the command line:

sudo sh -c '. /etc/mybot/env; curl -s "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"'

pending_update_count is how many updates are waiting for delivery, normally close to zero. last_error_message and last_error_date show the latest delivery error, explained under Troubleshooting. ip_address is where Telegram sends the requests.

Step 5. Deploy new versions without losing updates

An update counts as delivered once the bot confirms it: with polling, by the next getUpdates call with a higher offset; with a webhook, by a 2xx response. Telegram sends anything unconfirmed again.

  • Don't drop the queue. drop_pending_updates=True in setWebhook, deleteWebhook or at polling start throws away everything that arrived while the bot was down. Examples often include it; remove it.
  • Let the bot stop cleanly. We sent SIGTERM in the middle of a five-second handler. python-telegram-bot finished its queue and exited. grammY without a signal handler quit at once and got the same update again after the restart; with process.once("SIGTERM", () => bot.stop()) it finished the handler. aiogram 3 with default settings confirms an update before handling is done and cuts the handler off on stop: no reply was sent, and the update won't come back.
  • Push long work to a queue. Don't keep LLM calls, file conversion or broadcasts inside a handler: save the job to the database, reply right away, and let a worker do the job, including after a restart. In aiogram, handle_as_tasks=False turns loss into redelivery, but updates are then handled one at a time.

With polling, two copies can't coexist, so a deploy means stop, then start, and the updates from those seconds wait at Telegram. With a webhook you can deploy without a pause: start the new version on another port, switch proxy_pass, run sudo systemctl reload nginx and stop the old one. Your code must not delete the webhook on exit, or the old copy will switch off the new one.

Deploying by git tags:

sudo -u mybot -H git -C /srv/mybot/app fetch --tags
sudo -u mybot -H git -C /srv/mybot/app checkout v1.5.0
sudo -u mybot -H uv sync --locked --no-dev --directory /srv/mybot/app
sudo systemctl restart mybot
journalctl -u mybot -n 30 --no-pager

To roll back, run the same commands with the previous tag: uv.lock brings back that version's dependencies too. With Docker, tag images by release. Keep database migrations backward compatible (add a column in one release, drop the old one in the next), or the old code won't run on the new schema.

Step 6. Decide where to keep state

By default aiogram keeps FSM states in memory, grammY keeps sessions there, and python-telegram-bot keeps user_data. The aiogram docs explicitly advise against MemoryStorage in production: after every restart, a user in the middle of a dialog starts over.

  • SQLite suits a single process with moderate writes. Turn on PRAGMA journal_mode=WAL so reads don't block writes, and use an async driver such as aiosqlite.
  • PostgreSQL is for several processes, heavy writes or reporting: "PostgreSQL: install and configure".
  • Redis handles FSM states, sessions, caches and queues: "Redis: install and configure". In aiogram that's RedisStorage.from_url("redis://localhost:6379/0") and the aiogram[redis] package, in grammY @grammyjs/storage-redis. By default Redis only writes periodic snapshots to disk, so a crash loses everything since the last one. If dialog state matters, turn on appendonly yes to cut the loss to about a second.

Step 7. Stay within Telegram's limits

Telegram doesn't publish exact limits. The Bots FAQ gives these guidelines:

  • one chat: no more than one message per second, short bursts are fine;
  • one group: no more than 20 messages per minute;
  • broadcasts: about 30 messages per second, and the FAQ suggests spreading large broadcasts over 8–12 hours.

Going over returns 429 Too Many Requests with parameters.retry_after, the number of seconds to wait before retrying. Faster than 30 messages per second is only possible for a fee: the allow_paid_broadcast parameter raises the limit to 1000, at 0.1 Telegram Star per message.

Run broadcasts as a separate task, not inside a handler. The task takes recipients from the database, marks who got the message and keeps the rate below the limit:

import asyncio
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter

async def broadcast(bot, db, text: str, rate: float = 25) -> None:
    for user_id in await db.pending_recipients():
        while True:
            try:
                await bot.send_message(user_id, text)
                await db.mark_sent(user_id)
                break
            except TelegramRetryAfter as e:
                await asyncio.sleep(e.retry_after)
            except TelegramForbiddenError:
                await db.mark_blocked(user_id)
                break
        await asyncio.sleep(1 / rate)

TelegramForbiddenError means the user has blocked the bot, so leave them out of future broadcasts. In python-telegram-bot, AIORateLimiter from python-telegram-bot[rate-limiter] keeps the pace, but by default it doesn't retry after a 429, so set max_retries. The grammY docs advise against artificial throttling and recommend the auto-retry plugin, which waits out retry_after and retries.

Local Bot API server: when you need it

The cloud Bot API lets a bot download files up to 20 MB and upload up to 50 MB. Telegram's open-source telegram-bot-api server in --local mode lifts these limits: uploads up to 2000 MB, unlimited downloads, getFile returns a path on disk, and the webhook can point to a local HTTP address.

It's worth it for bots that handle video and large documents. Things to know:

  • you need an api_id and api_hash from my.telegram.org;
  • there's no official Docker image: build the server with the project's build instructions generator or use the unofficial aiogram/telegram-bot-api image;
  • by default the server listens on all interfaces with no authentication, so bind it to 127.0.0.1 with --http-ip-address;
  • files are stored in the server's working directory, so keep an eye on disk space.

Before switching, call logOut on the cloud API: after that, the bot can't return to the cloud for 10 minutes. Then point your framework at the local server: in aiogram, TelegramAPIServer.from_base("http://127.0.0.1:8081", is_local=True); in python-telegram-bot, base_url("http://127.0.0.1:8081/bot") and local_mode(True); in grammY, apiRoot.

How many resources a bot needs

We measured an echo bot's memory in Debian 13, idle and after a burst of 20,000 updates:

  • aiogram 3.31: 166 and 285 MB. About 130 MB goes to importing pydantic models for every Bot API type, a known aiogram issue;
  • python-telegram-bot 22.8: 49 and 81 MB;
  • grammY 1.46 on Node.js 24: 59 and 205 MB, as V8 is in no hurry to give memory back;
  • PostgreSQL 18 with a small database: about 50 MB; Redis: about 15 MB.

A bot barely needs CPU: the framework spends under a millisecond per update, so one core handles hundreds of updates per second. Telegram's limits and your own database and API calls will bite first.

On the smallest server, 1 vCPU and 2 GB of RAM, leave 400–500 MB for the system, nginx, PostgreSQL and Redis, and the rest fits 4–5 aiogram bots or 10–15 python-telegram-bot or grammY bots. Budget heavy work such as video conversion separately. Bots use little traffic: replies weigh kilobytes, and incoming traffic, which includes updates and files downloaded from Telegram, isn't counted towards the plan.

Monitoring and backups

  • Availability. Check a webhook bot with an HTTP request to a dedicated path such as /health. Have a polling bot call getMe once a minute and, if Telegram answers, ping a push monitor, the way cron jobs do in the Uptime Kuma guide.
  • Queue. A growing pending_update_count in getWebhookInfo means the bot is falling behind or down.
  • Restarts. A growing systemctl show mybot -p NRestarts means the bot is crash-looping.
  • SQLite backups: sudo sqlite3 /srv/mybot/data/bot.db ".backup '/var/backups/mybot/bot.db'" is safe on a live database, while cp without the WAL file gives you a corrupted copy.
  • PostgreSQL: pg_dump, see "Database backups (pg_dump / mysqldump)". Keep copies off the server: "Incremental backups with restic".

Troubleshooting

409 Conflict: terminated by other getUpdates request

  • A second copy with the same token is running: on your laptop, in another container, in a forgotten screen session, in PM2 cluster mode. It can also show up without errors in the log, as a bot that answers every other time or alternates between old and new code. Look for the copy with ps aux and docker ps; if you can't find it, issue a new token.

409 Conflict: can't use getUpdates method while webhook is active

  • The bot was switched to polling, but the webhook is still set. python-telegram-bot and grammY remove it on start, aiogram doesn't: call await bot.delete_webhook() before start_polling.

Errors in last_error_message

  • SSL error {...certificate verify failed}: nginx serves cert.pem instead of fullchain.pem, or the certificate has expired or was issued for another name. If getWebhookInfo shows has_custom_certificate: true while you use Let's Encrypt, call setWebhook again without the certificate parameter.
  • SSL error {...wrong version number}: the port speaks plain HTTP without TLS.
  • Wrong response from the webhook: 502 Bad Gateway: the bot isn't running or listens elsewhere, check systemctl status mybot and ss -ltnp. In Docker, the bot must listen on 0.0.0.0 inside the container, and the port should be published as 127.0.0.1:8080:8080.
  • 404: the path in setWebhook doesn't match the location. 301 or 307: a redirect to www or to a path with a trailing slash, and Telegram doesn't follow redirects.
  • 401 or 403: the secret changed in the environment but setWebhook wasn't called again, or a WAF blocks the requests.
  • 500: a handler crashed. Telegram delivers updates from one chat in order, so a failing update holds back everything after it in that chat: catch exceptions and respond with 200.
  • Read timeout expired: no response within 60 seconds, and Telegram will resend the update. Move heavy work to a queue.
  • Connection timed out or Connection refused: the port is closed in ufw, nginx isn't listening on 443, or the A record points to the old server. After a move, Telegram keeps sending requests to the old IP for up to half an hour: according to the telegram-bot-api sources, it caches DNS.

setWebhook returns 400 Bad Request

  • Webhook can be set up only on ports 80, 88, 443 or 8443: the URL uses another port.
  • IPv6-only addresses are not allowed: the domain has only an AAAA record.
  • secret token contains illegal characters: the secret contains +, / or =.

429 Too Many Requests: retry after N

  • The bot sends faster than the limit: wait out retry_after and send broadcasts through the queue from step 7.

The bot is silent after a server reboot

  • systemctl is-enabled mybot says disabled: enable was never run.
  • systemctl status complains about starting too quickly: the bot crashed 5 times in a row while the network was down. Check RestartSec and network-online.target, then run sudo systemctl reset-failed mybot and start the bot.
  • The container has no restart: unless-stopped, or PM2 was set up without pm2 startup and pm2 save.

Timeouts when reaching api.telegram.org

  • api.telegram.org has an IPv6 address, and on a server with broken IPv6 the client waits on it first. Where the server has IPv6, compare curl -4 -sI https://api.telegram.org with the same command using -6; for Node.js, NODE_OPTIONS=--dns-result-order=ipv4first helps.
  • Hosting in a country that blocks Telegram: see the warning at the top.

Sources