Как запустить Telegram-бота на VPS: systemd, вебхук и nginx
Как запустить Telegram-бота на сервере: polling или вебхук, systemd и Docker, nginx с Let's Encrypt, лимиты Telegram и обновление без потери апдейтов.
Ниже — как перенести на сервер бота, который уже работает у вас локально. Примеры на Python и aiogram 3, для python-telegram-bot и grammY указаны отличия. Актуально на сентябрь 2026 года: Bot API 10.3, aiogram 3.31, python-telegram-bot 22.8, grammY 1.46, uv 0.12.
Что понадобится
- VPS ForestsNet Cloud в Нидерландах или Германии на Ubuntu 24.04, Debian 12 или 13. Боту хватит 1 vCPU и 2 ГБ памяти, расчёт — в разделе о ресурсах. До
api.telegram.orgиз Нидерландов около 4 мс, из Франкфурта около 7 мс. - Доступ по SSH с
sudo: «Консоль VNC и первое подключение», вход по ключам — «Усиление SSH». - Токен бота от @BotFather.
- Для вебхука — домен или поддомен с A-записью на IP сервера: «Как привязать домен к серверу».
- Код в git вместе с lock-файлом зависимостей (
uv.lockилиpackage-lock.json): «Деплой из Git».
Шаг 1. Выберите polling или вебхук
Telegram отдаёт боту апдейты — сообщения, нажатия кнопок — одним из двух способов, и одновременно работает только один: пока задан вебхук, getUpdates отвечает ошибкой 409. Незабранные апдейты Telegram хранит до 24 часов, поэтому короткий простой ничего не теряет.
- Long polling (
getUpdates). Бот держит открытый запрос кapi.telegram.org, и Telegram отвечает, как только появился апдейт: задержка та же, что у вебхука. Домен, сертификат и открытые порты не нужны. Но апдейты забирает только один процесс: каждый новый запрос обрывает предыдущий, поэтому две копии бота делят апдейты между собой и сыплют ошибками 409 Conflict. - Вебхук (
setWebhook). Telegram сам присылает каждый апдейт HTTPS-запросом на ваш адрес, по умолчанию до 40 соединений параллельно (max_connections, максимум 100). Нужны домен, сертификат и порт 443, 80, 88 или 8443. Зато за прокси можно держать несколько копий бота, а один nginx обслуживает десятки ботов на разных путях.
Для одного бота на одном сервере берите long polling: он проще и не медленнее. Вебхук оправдан, когда нужны несколько экземпляров и обновление без паузы, когда ботов много или у бота уже есть веб-часть вроде Mini App, ради которой HTTPS всё равно поднимать.
Шаг 2. Подготовьте сервер
Бот работает от системного пользователя без shell и sudo. Домашний каталог — в /srv: /home закроет ProtectHome из шага 3.
sudo useradd --system --create-home --home-dir /srv/mybot --shell /usr/sbin/nologin mybot
sudo apt install -y git sqlite3Python через uv
uv ставит нужную версию Python, не трогая системную: в Ubuntu 24.04 это 3.12, в Debian 12 — 3.11, а коду может понадобиться другая.
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 ставит ровно то, что записано в uv.lock, и завершается ошибкой, если lock-файл разошёлся с pyproject.toml. Интерпретатор uv скачает сам в /srv/mybot/.local/share/uv/python, окружение создаст в /srv/mybot/app/.venv.
Node.js
Для бота на grammY поставьте Node.js 24 LTS по инструкции «Node.js приложение с PM2 и Nginx» и выполните npm ci --omit=dev в каталоге бота. PM2 годится только в режиме fork с одним экземпляром: в cluster-режиме каждая копия запустит свой polling. К тому же на остановку PM2 даёт процессу 1,6 секунды (kill_timeout), поэтому ниже — systemd.
Токен и секреты
sudo install -d -m 700 /etc/mybot
sudo nano /etc/mybot/env
sudo chmod 600 /etc/mybot/envBOT_TOKEN=1234567890:AAH-замените-на-свой
WEBHOOK_SECRET=вывод-openssl-rand-hex-32Файл читает systemd от root до запуска бота, так что он может принадлежать root с правами 600: процесс получит переменные, но прочитать файл не сможет. Формат строгий: КЛЮЧ=значение, без export — такую строку systemd пропустит — и без комментариев в конце строки, иначе они попадут в значение. Если код сам читает .env через python-dotenv, отдайте файл пользователю mybot.
Шаг 3. Запустите бота через systemd
Создайте /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 -fЧто здесь важно:
Wants=вместе сAfter=network-online.target. БезWants=эта цель может не попасть в загрузку. Если бот стартует раньше сети, aiogram и python-telegram-bot сразу завершаются: первый запросgetMeу них без повторов. grammY ждёт сеть сам.RestartSec=5. По умолчанию systemd перезапускает через 100 мс и после 5 запусков за 10 секунд сдаётся: юнит остаётся вfailed, бот молчит до ручного запуска. С паузой в 5 секунд предел не достигается. В systemd 254 и новее — Ubuntu 24.04 и Debian 13, но не Debian 12 — паузу можно наращивать:RestartSteps=5иRestartMaxDelaySec=60..venv/bin/pythonнапрямую:uv runпри каждом запуске сверяет окружение с lock-файлом и остаётся лишним процессом между systemd и ботом.PYTHONUNBUFFERED=1. Без терминала Python буферизует stdout блоками, и выводprint()приходит в журнал с опозданием.loggingпишет в stderr, а он построчный.ProtectSystem=strictоставляет запись только вReadWritePaths, туда кладите базу SQLite.
Для Node.js замените ExecStart на /usr/bin/node /srv/mybot/app/bot.js, а PYTHONUNBUFFERED — на NODE_ENV=production. Проверьте автозапуск: sudo reboot, затем systemctl is-active mybot и journalctl -u mybot -b.
Вариант с Docker
Если на сервере уже есть Docker — «Docker и Docker Compose», — соберите образ с 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: localCMDв JSON-форме. СтрокаCMD python -m botзапускает бота через/bin/sh, который не передаёт SIGTERM, и черезstop_grace_period— по умолчанию 10 секунд — Docker убивает процесс.- Ротация логов. Драйвер по умолчанию
json-fileлоги не ротирует, и бот со временем забивает диск.localхранит до 100 МБ на контейнер. - Одна копия.
docker compose up --scale bot=2для бота на polling даёт 409 Conflict.
Шаг 4. Настройте вебхук: домен, сертификат, nginx
Правило ForestsNet Cloud: вебхук — только на своём домене и со своим сертификатом. Чужой домен в сертификате запрещают Условия использования, сертификаты на публичных портах проверяются автоматически. Telegram примет и самоподписанный сертификат, но его придётся загружать в setWebhook при каждой замене. Let's Encrypt бесплатен и продлевается сам.
Требования Telegram: HTTPS с полной цепочкой сертификата, TLS 1.2 и новее, порт 443, 80, 88 или 8443, только IPv4, без редиректов. Запросы приходят из сетей 149.154.160.0/20 и 91.108.4.0/22, но списки могут меняться, так что защищайтесь секретом, а не фильтром по адресам.
Поставьте nginx и получите сертификат — «Nginx: установка и первый сайт» и «Бесплатный SSL с Let's Encrypt», — затем добавьте в блок server домена бота:
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;
}Бот слушает только 127.0.0.1:8080, снаружи открыты 80 и 443 — «Файрвол ufw: порты». В ssl_certificate должен стоять fullchain.pem, а не cert.pem: без промежуточного сертификата браузер сайт откроет, а Telegram — нет.
С Caddy сертификат выпускается сам — «Caddy: автоматический HTTPS». Блоки handle обязательны: без них respond сработает раньше reverse_proxy.
bot.example.com {
handle /tg/webhook {
reverse_proxy 127.0.0.1:8080
}
handle {
respond 404
}
}secret_token и код бота
secret_token — строка до 256 символов из латиницы, цифр, _ и -, которую Telegram кладёт в заголовок X-Telegram-Bot-Api-Secret-Token каждого запроса. Бот отклоняет запросы без неё, так что подделать апдейт, зная адрес, нельзя. Сгенерируйте её командой openssl rand -hex 32: base64 с +, / и = Telegram не примет. Вебхук на aiogram 3 по образцу официального примера:
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() # сюда подключите роутеры: 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()В python-telegram-bot то же делает application.run_webhook(listen="127.0.0.1", port=8080, url_path="tg/webhook", webhook_url=..., secret_token=...) из пакета python-telegram-bot[webhooks]. В grammY — webhookCallback(bot, "express", { secretToken }): он ждёт обработчик 10 секунд, потом отвечает ошибкой.
Проверьте вебхук, не вводя токен в командную строку:
sudo sh -c '. /etc/mybot/env; curl -s "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"'pending_update_count — сколько апдейтов ждёт доставки, в норме около нуля. last_error_message и last_error_date — последняя ошибка доставки, расшифровка в разделе «Диагностика». ip_address — куда Telegram шлёт запросы.
Шаг 5. Обновляйте без потери апдейтов
Апдейт считается доставленным, когда бот его подтвердил: при polling — следующим getUpdates со сдвинутым offset, при вебхуке — ответом 2xx. Неподтверждённое Telegram пришлёт снова.
- Не сбрасывайте очередь.
drop_pending_updates=TrueвsetWebhook,deleteWebhookили при запуске polling выбрасывает всё, что пришло, пока бот был выключен. Этот параметр часто копируют из примеров — уберите его. - Дайте боту остановиться штатно. Мы послали SIGTERM посреди пятисекундного обработчика. python-telegram-bot доработал очередь и вышел. grammY без обработчика сигнала завершился сразу и после старта получил тот же апдейт повторно, а с
process.once("SIGTERM", () => bot.stop())доработал. aiogram 3 с настройками по умолчанию подтверждает апдейт до конца обработки и при остановке обрывает обработчик: ответ не ушёл, повторно апдейт не придёт. - Долгую работу — в очередь. Запросы к нейросетям, конвертацию файлов и рассылки не держите в обработчике: запишите задачу в базу и ответьте сразу, а воркер выполнит её и после перезапуска. В aiogram
handle_as_tasks=Falseменяет потерю на повторную доставку, но апдейты обрабатываются по одному.
При polling две копии не уживаются, поэтому обновление — это остановка и запуск, а апдейты за эти секунды дождутся у Telegram. С вебхуком можно без паузы: запустите новую версию на соседнем порту, переключите proxy_pass, выполните sudo systemctl reload nginx и остановите старую. Код не должен снимать вебхук при выходе, иначе старая копия отключит новую.
Деплой по git-тегам:
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Откат — те же команды с предыдущим тегом: uv.lock вернёт и зависимости той версии, а образы Docker помечайте тегом релиза. Миграции базы делайте обратно совместимыми — колонку сначала добавить, удалить в следующем релизе, — иначе старый код не заработает на новой схеме.
Шаг 6. Решите, где хранить состояние
По умолчанию aiogram держит состояния FSM в памяти, как и grammY — сессии, а python-telegram-bot — user_data. Документация aiogram прямо не советует MemoryStorage для продакшна: после каждого перезапуска пользователь посреди диалога начинает сначала.
- SQLite — для одного процесса и умеренной записи. Включите
PRAGMA journal_mode=WAL, чтобы чтение не блокировало запись, и берите асинхронный драйвер вроде aiosqlite. - PostgreSQL — когда процессов несколько, записи много или нужны отчёты: «PostgreSQL: установка и настройка».
- Redis — для FSM, сессий, кэша и очередей: «Redis: установка и настройка». В aiogram это
RedisStorage.from_url("redis://localhost:6379/0")и пакетaiogram[redis], в grammY —@grammyjs/storage-redis. По умолчанию Redis пишет на диск только периодические снимки, и при сбое пропадает всё, что изменилось после последнего. Если состояние диалогов важно, включитеappendonly yes: потеря сократится до секунды.
Шаг 7. Соблюдайте лимиты Telegram
Точных лимитов Telegram не публикует. Ориентиры из FAQ для ботов:
- в один чат — не чаще сообщения в секунду, короткие всплески допустимы;
- в одну группу — не больше 20 сообщений в минуту;
- рассылка — около 30 сообщений в секунду, а большие рассылки FAQ советует растягивать на 8–12 часов.
Превышение даёт ошибку 429 Too Many Requests с полем parameters.retry_after — столько секунд нужно подождать перед повтором. Быстрее 30 сообщений в секунду можно только платно: параметр allow_paid_broadcast поднимает предел до 1000, сообщение стоит 0,1 Telegram Star.
Рассылку ведите отдельной задачей, а не в обработчике: она берёт получателей из базы, отмечает отправленных и держит темп ниже лимита:
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 означает, что пользователь заблокировал бота, — таких исключайте из рассылок. В python-telegram-bot темп держит AIORateLimiter из пакета python-telegram-bot[rate-limiter], но после 429 он по умолчанию не повторяет запрос — задайте max_retries. Документация grammY советует не тормозить искусственно, а ставить плагин auto-retry: он выжидает retry_after и повторяет.
Локальный Bot API server: когда нужен
Облачный Bot API отдаёт боту файлы до 20 МБ и принимает до 50 МБ. Открытый сервер telegram-bot-api от Telegram в режиме --local снимает эти пределы: загрузка до 2000 МБ, скачивание без ограничений, getFile возвращает путь к файлу на диске, а вебхук можно направить на локальный HTTP-адрес.
Нужен он ботам, работающим с видео и большими документами. Что учесть:
- нужны
api_idиapi_hashс my.telegram.org; - официального образа Docker нет: соберите сервер по генератору инструкций проекта или возьмите неофициальный образ
aiogram/telegram-bot-api; - по умолчанию сервер слушает все интерфейсы без авторизации — привяжите его к
127.0.0.1ключом--http-ip-address; - файлы хранятся в рабочем каталоге сервера, следите за местом на диске.
Перед переключением вызовите на облачном API logOut: вернуть бота в облако после этого можно только через 10 минут. Затем укажите адрес сервера: в aiogram — TelegramAPIServer.from_base("http://127.0.0.1:8081", is_local=True), в python-telegram-bot — base_url("http://127.0.0.1:8081/bot") и local_mode(True), в grammY — apiRoot.
Сколько ресурсов нужно боту
Мы замерили память эхо-бота в Debian 13 — в простое и после пачки из 20 000 апдейтов:
- aiogram 3.31 — 166 и 285 МБ. Около 130 МБ уходит на импорт моделей pydantic для всех типов Bot API, это известная проблема aiogram;
- python-telegram-bot 22.8 — 49 и 81 МБ;
- grammY 1.46 на Node.js 24 — 59 и 205 МБ: V8 не спешит отдавать память;
- PostgreSQL 18 с небольшой базой — около 50 МБ, Redis — около 15 МБ.
Процессор боту почти не нужен: фреймворк тратит меньше миллисекунды на апдейт, и одно ядро справляется с сотнями апдейтов в секунду. Раньше бот упрётся в лимиты Telegram и в свои запросы к базе и внешним API.
Для самого маленького сервера с 1 vCPU и 2 ГБ памяти оставьте 400–500 МБ системе, nginx, PostgreSQL и Redis, а на остальном поместятся 4–5 ботов на aiogram или 10–15 на python-telegram-bot и grammY. Тяжёлую работу вроде конвертации видео считайте отдельно. Трафика боты тратят мало: ответы весят килобайты, а входящий трафик — апдейты и файлы от Telegram — в пакет не входит.
Мониторинг и бэкапы
- Доступность. Бота на вебхуке проверяйте HTTP-запросом к отдельному пути вроде
/health. Бот на polling пусть раз в минуту вызываетgetMeи, если Telegram ответил, стучится в push-монитор, как cron-задания в статье про Uptime Kuma. - Очередь. Растущий
pending_update_countвgetWebhookInfoзначит, что бот не успевает или лежит. - Перезапуски. Растёт
systemctl show mybot -p NRestarts— бот падает в цикле. - Бэкап SQLite —
sudo sqlite3 /srv/mybot/data/bot.db ".backup '/var/backups/mybot/bot.db'": команда безопасна на работающей базе, аcpбез WAL-файла даёт повреждённую копию. - PostgreSQL —
pg_dump, см. «Бэкапы баз данных». Копии уносите с сервера: «Инкрементальные бэкапы с restic».
Диагностика
409 Conflict: terminated by other getUpdates request
- Работает вторая копия с тем же токеном: на ноутбуке, во втором контейнере, в забытом
screen, в cluster-режиме PM2. Бывает и без ошибок в логе: бот отвечает через раз или то новой, то старой версией. Ищите копию черезps auxиdocker ps, не нашли — перевыпустите токен.
409 Conflict: can't use getUpdates method while webhook is active
- Бота перевели на polling, а вебхук остался. python-telegram-bot и grammY снимают его при старте сами, aiogram — нет: вызовите
await bot.delete_webhook()передstart_polling.
Ошибки в last_error_message
SSL error {...certificate verify failed}— в nginxcert.pemвместоfullchain.pem, сертификат истёк или выписан на другое имя. Если при сертификате Let's Encrypt вgetWebhookInfoстоитhas_custom_certificate: true, вызовитеsetWebhookзаново без параметраcertificate.SSL error {...wrong version number}— на порту отвечает HTTP без TLS.Wrong response from the webhook: 502 Bad Gateway— бот не запущен или слушает другой адрес: проверьтеsystemctl status mybotиss -ltnp. В Docker бот внутри контейнера должен слушать0.0.0.0, а порт публикуйте как127.0.0.1:8080:8080.404— путь вsetWebhookне совпадает сlocation.301или307— редирект наwwwили на путь со слешем в конце: по редиректам Telegram не ходит.401или403— секрет в окружении сменили, аsetWebhookне вызвали, либо запросы режет WAF.500— обработчик упал. Апдейты одного чата Telegram доставляет по порядку, и такой апдейт задерживает все следующие в этом чате: ловите исключения и отвечайте 200.Read timeout expired— ответа не было 60 секунд, Telegram повторит апдейт. Тяжёлую работу выносите в очередь.Connection timed outилиConnection refused— порт закрыт в ufw, nginx не слушает 443 или A-запись указывает на старый сервер. После переезда Telegram ещё до получаса шлёт запросы на старый IP: по исходникам telegram-bot-api он кэширует DNS.
setWebhook отвечает 400 Bad Request
Webhook can be set up only on ports 80, 88, 443 or 8443— в адресе другой порт.IPv6-only addresses are not allowed— у домена только AAAA-запись.secret token contains illegal characters— в секрете есть+,/или=.
429 Too Many Requests: retry after N
- Бот шлёт быстрее лимита: выжидайте
retry_afterи ведите рассылки через очередь из шага 7.
Бот молчит после перезагрузки сервера
systemctl is-enabled mybotпоказываетdisabled— не выполненenable.systemctl statusсообщает о слишком частых запусках: бот упал 5 раз подряд, пока не было сети. ПроверьтеRestartSecиnetwork-online.target, затем выполнитеsudo systemctl reset-failed mybotи запустите бота.- У контейнера нет
restart: unless-stopped, в PM2 не выполненыpm2 startupиpm2 save.
Таймауты при обращении к api.telegram.org
- У
api.telegram.orgесть IPv6-адрес, и на сервере со сломанным IPv6 клиент сначала ждёт его. Где IPv6 есть, сравнитеcurl -4 -sI https://api.telegram.orgи то же с-6; для Node.js помогаетNODE_OPTIONS=--dns-result-order=ipv4first. - Сервер в России — см. предупреждение в начале статьи.
Источники
- Telegram Bot API, гайд по вебхукам, FAQ: лимиты
- tdlib/telegram-bot-api и генератор инструкций по сборке
- telegram-bot-api: таймаут вебхука 60 секунд, issue #341, совет ставить бота на сервер со свободным доступом, issue #822
- aiogram: вебхуки, хранилища FSM, issue #1397 о подтверждении апдейтов, issue #1513 о памяти, issue #1394 об IPv6
- python-telegram-bot: Webhooks, Avoiding flood limits, AIORateLimiter
- grammY: Long Polling vs. Webhooks, Flood Limits, Reliability, issue #585 об IPv6
- uv: Docker, хранение данных
- systemd.service, systemd.exec, network-online.target
- Docker: драйвер логов local, Redis: persistence, SQLite: How To Corrupt
- OONI: блокировка Telegram в России, 2026, ntc.party: блокировка api.telegram.org, март 2026, Хабр: вебхук бота на российском хостинге, сентябрь 2026