Deploying an aiogram 3 Bot Step by Step
Build a structured aiogram 3 bot with routers, middleware and a database, then deploy it to an always-on server with environment variables and clean shutdowns.
On this page
aiogram is one of the most popular Python frameworks for Telegram bots — fully asynchronous, well typed and built around routers that keep large bots organised. This guide builds a small but properly structured aiogram 3 bot and deploys it to a server that keeps it running 24/7.
Project layout
tg-bot/
├── bot/
│ ├── __init__.py
│ ├── __main__.py
│ ├── config.py
│ ├── db.py
│ └── handlers/
│ ├── __init__.py
│ ├── start.py
│ └── notes.py
├── requirements.txt
└── .gitignore
Running the bot with python -m bot executes bot/__main__.py. Handlers live in separate router modules so features stay independent.
requirements.txt:
aiogram>=3.4,<4
aiosqlite>=0.20
.gitignore:
.venv/
__pycache__/
.env
*.db
Configuration from the environment
bot/config.py reads every setting from environment variables, failing loudly if something required is missing:
import os
BOT_TOKEN = os.environ["BOT_TOKEN"]
ADMIN_IDS = {int(x) for x in os.environ.get("ADMIN_IDS", "").split(",") if x}
DB_PATH = os.environ.get("DB_PATH", "data.db")
Admin IDs are numeric Telegram user IDs. Usernames can change or be taken over by someone else; IDs can’t. See securing your Telegram bot token and admin commands.
A router with handlers
bot/handlers/start.py:
from aiogram import Router
from aiogram.filters import CommandStart, Command
from aiogram.types import Message
router = Router(name="start")
@router.message(CommandStart())
async def cmd_start(message: Message):
await message.answer(
f"Hi, {message.from_user.first_name}! Send /note <text> to save a note, /notes to list them."
)
@router.message(Command("help"))
async def cmd_help(message: Message):
await message.answer("Commands: /note, /notes, /help")
bot/handlers/notes.py uses a small database module:
from aiogram import Router
from aiogram.filters import Command, CommandObject
from aiogram.types import Message
from bot import db
router = Router(name="notes")
@router.message(Command("note"))
async def add_note(message: Message, command: CommandObject):
if not command.args:
await message.answer("Usage: /note <text>")
return
await db.add_note(message.from_user.id, command.args[:500])
await message.answer("Saved.")
@router.message(Command("notes"))
async def list_notes(message: Message):
notes = await db.list_notes(message.from_user.id)
await message.answer("\n".join(f"• {n}" for n in notes) or "No notes yet.")
CommandObject gives you the text after the command, already parsed.
A tiny database layer
bot/db.py with aiosqlite:
import aiosqlite
from bot.config import DB_PATH
_conn: aiosqlite.Connection | None = None
async def connect():
global _conn
_conn = await aiosqlite.connect(DB_PATH)
await _conn.execute(
"CREATE TABLE IF NOT EXISTS notes (user_id INTEGER, text TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)"
)
await _conn.commit()
async def close():
if _conn:
await _conn.close()
async def add_note(user_id: int, text: str):
await _conn.execute("INSERT INTO notes (user_id, text) VALUES (?, ?)", (user_id, text))
await _conn.commit()
async def list_notes(user_id: int) -> list[str]:
async with _conn.execute(
"SELECT text FROM notes WHERE user_id = ? ORDER BY created_at DESC LIMIT 20", (user_id,)
) as cur:
return [row[0] async for row in cur]
The X | None syntax needs Python 3.10 or newer; on 3.9, use Optional[aiosqlite.Connection]. SQLite is perfect for a small bot, and the file persists across restarts on Kerit Cloud because storage is persistent NVMe. When the bot grows, switch to MySQL or PostgreSQL — see storing Telegram bot state.
The entry point
bot/__main__.py wires everything together and handles startup and shutdown:
import asyncio
import logging
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from bot import db
from bot.config import BOT_TOKEN
from bot.handlers import notes, start
async def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
bot = Bot(BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher()
dp.include_routers(start.router, notes.router)
dp.startup.register(db.connect)
dp.shutdown.register(db.close)
await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
A few details matter here:
DefaultBotProperties(parse_mode=...)sets HTML formatting for every message. That’s why the start message escapes<text>as<text>— with HTML parse mode, unescaped angle brackets cause a “can’t parse entities” error.delete_webhook(drop_pending_updates=True)ensures polling works even if a webhook was set earlier, and skips updates that piled up while the bot was down. Drop that flag if you’d rather process the backlog.- Startup and shutdown hooks open and close the database. aiogram’s polling handles SIGINT and SIGTERM and runs shutdown hooks, so restarts close the database cleanly.
Add an error handler
A handler exception shouldn’t disappear silently. Register a global error handler:
from aiogram.types import ErrorEvent
@dp.error()
async def on_error(event: ErrorEvent):
logging.exception("Update %s failed", event.update.update_id, exc_info=event.exception)
Test locally
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export BOT_TOKEN=123456:your-token
python -m bot
Send /start, /note buy milk and /notes. Then stop the local bot — only one process can poll a bot at a time.
Deploy
- Create a server on Kerit Cloud’s free plan or a Telegram bot plan with a Python 3.10–3.12 runtime.
- Upload the project via the file manager or SFTP, or link your git repository on a paid plan for push-to-deploy.
- Add environment variables:
BOT_TOKEN, and optionallyADMIN_IDSandDB_PATH. - Set the start command to
python -u -m bot. - Start the server and watch the console for aiogram’s “Start polling” log line.
Dependencies install automatically from requirements.txt. From then on, crashes restart within seconds, and the SQLite file stays put across restarts and redeploys.
Multi-step conversations with FSM
Many bots need to ask several questions in a row — a sign-up form, a support request, an order. aiogram’s finite state machine tracks where each user is in a conversation:
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import Message
router = Router(name="feedback")
class Feedback(StatesGroup):
topic = State()
details = State()
@router.message(Command("feedback"))
async def start_feedback(message: Message, state: FSMContext):
await state.set_state(Feedback.topic)
await message.answer("What's it about?")
@router.message(Feedback.topic, F.text)
async def got_topic(message: Message, state: FSMContext):
await state.update_data(topic=message.text)
await state.set_state(Feedback.details)
await message.answer("Tell me more.")
@router.message(Feedback.details, F.text)
async def got_details(message: Message, state: FSMContext):
data = await state.update_data(details=message.text)
await state.clear()
await message.answer(f"Thanks! Logged feedback about: {data['topic']}")
By default, aiogram keeps FSM state in memory, which means a restart forgets every conversation in progress. For production, pass a persistent storage to the dispatcher — RedisStorage is the standard choice (Dispatcher(storage=RedisStorage.from_url(os.environ["REDIS_URL"])), installed with the redis package). Kerit Cloud’s Ultra plan includes Redis alongside MySQL and PostgreSQL.
A simple rate-limit middleware
Middleware runs before your handlers, which makes it the right place for cross-cutting concerns like throttling. A minimal per-user limiter:
import time
from aiogram import BaseMiddleware
class Throttle(BaseMiddleware):
def __init__(self, seconds: float = 0.7):
self.seconds = seconds
self.last: dict[int, float] = {}
async def __call__(self, handler, event, data):
user = data.get("event_from_user")
if user:
now = time.monotonic()
if now - self.last.get(user.id, 0) < self.seconds:
return # drop the update quietly
self.last[user.id] = now
return await handler(event, data)
dp.message.middleware(Throttle())
This keeps one user from flooding your bot and, by extension, from pushing your bot into Telegram’s own send limits. For a bot with many users, prune the dictionary periodically or use Redis with expiring keys. Telegram’s limits are covered in Telegram Bot API limits.
Common errors
TelegramBadRequest: can't parse entities — the message contains characters that break the parse mode. With HTML, escape <, > and & in user-supplied text using html.escape(); aiogram’s html_decoration.quote() (from aiogram.utils.text_decorations) does the same job.
TelegramConflictError — another process is polling the same bot, or a webhook is set. Stop the other copy; the delete_webhook call at startup handles the second case.
KeyError: 'BOT_TOKEN' — the environment variable isn’t set on the server.
TelegramForbiddenError: bot was blocked by the user — normal when a user blocks your bot. Catch it, mark the user inactive, and stop messaging them.
Next steps
- Use aiogram’s FSM (finite state machine) for multi-step conversations, with a persistent storage backend so state survives restarts — Redis storage works well on plans that include Redis.
- Add middleware for per-user rate limiting and logging.
- Move scheduled work into background tasks — see scheduling messages and background jobs.
Summary
A maintainable aiogram 3 bot splits handlers into routers, reads configuration from environment variables, opens and closes resources through startup and shutdown hooks, and logs errors through a global handler. Deploy it with requirements.txt, a BOT_TOKEN variable and python -u -m bot, and it runs 24/7 with clean restarts and persistent data.