Как запустить 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.

Что понадобится

Важно: не размещайте бота на сервере в России. С марта 2026 года Telegram там заблокирован: с российских площадок запросы к Bot API зависают, а у кого-то не доходят входящие вебхуки. Ведущий разработчик telegram-bot-api советует ставить бота туда, где доступ к Telegram свободный.

Шаг 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 sqlite3

Python через 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/env
BOT_TOKEN=1234567890:AAH-замените-на-свой
WEBHOOK_SECRET=вывод-openssl-rand-hex-32

Файл читает systemd от root до запуска бота, так что он может принадлежать root с правами 600: процесс получит переменные, но прочитать файл не сможет. Формат строгий: КЛЮЧ=значение, без export — такую строку systemd пропустит — и без комментариев в конце строки, иначе они попадут в значение. Если код сам читает .env через python-dotenv, отдайте файл пользователю mybot.

Важно: если токен попал в git, логи или чат, перевыпустите его в @BotFather командой /token: старый перестанет действовать.

Шаг 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.target
sudo -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: local
  • CMD в 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} — в nginx cert.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.
  • Сервер в России — см. предупреждение в начале статьи.

Источники