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
- A ForestsNet Cloud VPS in the Netherlands or Germany running Ubuntu 24.04, Debian 12 or 13. A bot fits in 1 vCPU and 2 GB of RAM, see the resources section for numbers. The round trip to
api.telegram.orgis about 4 ms from the Netherlands and 7 ms from Frankfurt. - SSH access with
sudo: "VNC console and first connection over SSH", key-only login: "How to harden SSH on your VPS". - A bot token from @BotFather.
- For a webhook: a domain or subdomain with an A record pointing to the server, see "Pointing a domain at your server".
- Your code in git together with a dependency lock file (
uv.lockorpackage-lock.json): "Deploying from Git".
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 toapi.telegram.orgopen, 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 sqlite3Python 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/envBOT_TOKEN=1234567890:AAH-replace-with-yours
WEBHOOK_SECRET=output-of-openssl-rand-hex-32systemd 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.
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.targetsudo -u mybot mkdir -p /srv/mybot/data
sudo systemctl daemon-reload
sudo systemctl enable --now mybot
journalctl -u mybot -fWhat matters here:
Wants=together withAfter=network-online.target. WithoutWants=, 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 firstgetMecall 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 staysfailed, 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 withRestartSteps=5andRestartMaxDelaySec=60..venv/bin/pythondirectly:uv runchecks 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, andprint()output reaches the journal late.loggingwrites to stderr, which is line-buffered.ProtectSystem=strictleaves onlyReadWritePathswritable, 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: localCMDin JSON form.CMD python -m botruns the bot through/bin/sh, which doesn't pass SIGTERM on, so afterstop_grace_period(10 seconds by default) Docker kills the process.- Log rotation. The default
json-filedriver doesn't rotate logs, and over time the bot fills the disk.localkeeps up to 100 MB per container. - One copy.
docker compose up --scale bot=2for 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=TrueinsetWebhook,deleteWebhookor 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=Falseturns 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-pagerTo 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=WALso 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 theaiogram[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 onappendonly yesto 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_idandapi_hashfrom 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-apiimage; - by default the server listens on all interfaces with no authentication, so bind it to
127.0.0.1with--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 callgetMeonce 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_countingetWebhookInfomeans the bot is falling behind or down. - Restarts. A growing
systemctl show mybot -p NRestartsmeans 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, whilecpwithout 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
screensession, 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 withps auxanddocker 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()beforestart_polling.
Errors in last_error_message
SSL error {...certificate verify failed}: nginx servescert.peminstead offullchain.pem, or the certificate has expired or was issued for another name. IfgetWebhookInfoshowshas_custom_certificate: truewhile you use Let's Encrypt, callsetWebhookagain without thecertificateparameter.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, checksystemctl status mybotandss -ltnp. In Docker, the bot must listen on0.0.0.0inside the container, and the port should be published as127.0.0.1:8080:8080.404: the path insetWebhookdoesn't match thelocation.301or307: a redirect towwwor to a path with a trailing slash, and Telegram doesn't follow redirects.401or403: the secret changed in the environment butsetWebhookwasn'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 outorConnection 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_afterand send broadcasts through the queue from step 7.
The bot is silent after a server reboot
systemctl is-enabled mybotsaysdisabled:enablewas never run.systemctl statuscomplains about starting too quickly: the bot crashed 5 times in a row while the network was down. CheckRestartSecandnetwork-online.target, then runsudo systemctl reset-failed mybotand start the bot.- The container has no
restart: unless-stopped, or PM2 was set up withoutpm2 startupandpm2 save.
Timeouts when reaching api.telegram.org
api.telegram.orghas an IPv6 address, and on a server with broken IPv6 the client waits on it first. Where the server has IPv6, comparecurl -4 -sI https://api.telegram.orgwith the same command using-6; for Node.js,NODE_OPTIONS=--dns-result-order=ipv4firsthelps.- Hosting in a country that blocks Telegram: see the warning at the top.
Sources
- Telegram Bot API, webhook guide, Bots FAQ: limits
- tdlib/telegram-bot-api and its build instructions generator
- telegram-bot-api: the 60-second webhook timeout, issue #341, advice to run the bot where Telegram is reachable, issue #822
- aiogram: webhooks, FSM storages, issue #1397 on update confirmation, issue #1513 on memory use, issue #1394 on IPv6
- python-telegram-bot: Webhooks, Avoiding flood limits, AIORateLimiter
- grammY: Long Polling vs. Webhooks, Flood Limits, Reliability, issue #585 on IPv6
- uv: Docker, storage locations
- systemd.service, systemd.exec, network-online.target
- Docker: the local logging driver, Redis persistence, SQLite: How To Corrupt
- OONI: Telegram blocked in Russia, 2026