A Telegram bot is one of the easiest things to write and one of the easiest to lose. It works perfectly in your terminal, and then you close the laptop. You move it to a free tier that sleeps after fifteen idle minutes, and the first person to message it in the morning waits forty seconds for a reply. You park it on a server inside a screen session, and it survives exactly until the next reboot.
None of that is a Telegram problem. A bot is an ordinary long-running process, and it keeps dying because it is being run like a script. This guide fixes that end to end: choosing between long polling and webhooks, a Dockerfile that restarts cleanly, keeping state in Postgres instead of a JSON file next to the code, scheduling without sending everything twice, and what running the thing around the clock actually costs.
Long polling or webhooks?
Telegram offers two ways to receive updates, and the choice decides what kind of hosting you need — so make it before you compare platforms.
Long polling means your bot opens an outbound HTTPS request to api.telegram.org and holds it until an update arrives or the timeout expires, then opens another. Nothing connects to you. There is no public domain, no TLS certificate, no inbound port and no difference between running it on your laptop and running it in a container. What it needs is a process that never stops.
Webhooks mean you register an HTTPS URL with setWebhook and Telegram POSTs each update to it. Telegram will only deliver to a valid certificate on port 443, 80, 88 or 8443, so you need a public domain and working TLS. In exchange you hold no connection open and the update arrives without a polling round trip.
For almost every bot that is not measurably busy, polling is the right default, and the reason is operational rather than technical: it removes the domain, the certificate and the inbound firewall rule from the list of things that can be broken at three in the morning. The one rule polling imposes is that a single token may only be polled by one process at a time — start a second and Telegram answers 409 Conflict for both.
Take webhooks when the bot is genuinely high-volume, or already part of an HTTP service you were deploying anyway. Otherwise polling turns the hosting question from “where do I get a public HTTPS endpoint?” into “where can I run a process that stays alive?” — a much cheaper question to answer.
A Dockerfile that actually restarts
Here is the whole bot. It polls, it answers /start, and it shuts down properly when the platform asks it to.
import asyncio
import logging
import os
from aiogram import Bot, Dispatcher
from aiogram.filters import CommandStart
from aiogram.types import Message
dp = Dispatcher()
@dp.message(CommandStart())
async def start(message: Message) -> None:
await message.answer("Up and running. I will still be here tomorrow.")
async def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
bot = Bot(token=os.environ["BOT_TOKEN"])
# handle_signals defaults to True: aiogram installs SIGINT and SIGTERM handlers, stops long
# polling and lets the handlers already running finish before this call returns.
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())Two dependencies, both pinned, because an unpinned bot is a bot that breaks on a rebuild you did not intend to make:
aiogram==3.31.0
asyncpg==0.31.0And the image:
FROM python:3.13-slim AS build
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 bot
COPY --from=build /opt/venv /opt/venv
WORKDIR /app
COPY --chown=bot:bot bot.py .
USER bot
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
STOPSIGNAL SIGTERM
CMD ["python", "bot.py"]Four lines in there are doing the work that keeps the bot up. The build stage installs into a virtualenv that gets copied into a clean base image, so pip, its cache and the build dependencies never ship — the same trick as in multi-stage builds, and worth reading if the Dockerfile above is the first one you have written. USER bot means a compromised bot token does not also hand over root inside the container. PYTHONUNBUFFERED=1 is the difference between seeing your logs and staring at an empty log window, because Python buffers stdout when it is not a terminal. And STOPSIGNAL SIGTERM states explicitly what the platform will send when it replaces the container.
That last one is the part people improvise around. The instinct is to write while True: with a try/except that swallows everything, so the bot can never crash. Do not: a process that cannot die also cannot be restarted cleanly, it keeps running after a failed deploy, and it hides the error that made it fall over in the first place. Let the process exit and let the platform start it again — that is what a container supervisor is for, and it is the same reason a container that restarts forever is a symptom rather than a design. If you have never written one of these, Dockerfile basics covers what each instruction is for, and microcontainers covers how far the size can come down.
Keeping state: Postgres, not a JSON file
Every bot accumulates state — who has subscribed, what step of a form someone is on, which message ids to edit later. The tempting home for it is state.json beside the code, and it works right up to the first deploy. A container filesystem is discarded when the container is replaced, so the file goes with it, along with everyone's subscriptions.
A very small table is enough:
CREATE TABLE IF NOT EXISTS chats (
chat_id bigint PRIMARY KEY,
started_at timestamptz NOT NULL DEFAULT now()
);Wire it up by opening one pool at start-up and handing it to the handlers. aiogram passes any keyword argument given to start_polling straight into the handler signature:
import asyncpg
@dp.message(CommandStart())
async def start(message: Message, pool: asyncpg.Pool) -> None:
await pool.execute(
"INSERT INTO chats (chat_id) VALUES ($1) ON CONFLICT (chat_id) DO NOTHING",
message.chat.id,
)
await message.answer("Up and running. I will still be here tomorrow.")
async def main() -> None:
pool = await asyncpg.create_pool(os.environ["DATABASE_URL"], min_size=1, max_size=5)
bot = Bot(token=os.environ["BOT_TOKEN"])
try:
await dp.start_polling(bot, pool=pool)
finally:
await pool.close()max_size=5 is deliberate. A bot handling a handful of messages a second does not need twenty sockets, and the smallest managed Postgres instances have modest connection limits — how connection pools work is the longer version of why that number matters more than it looks. Both BOT_TOKEN and DATABASE_URL arrive as environment variables and neither belongs in the repository; on a platform with managed databases the connection string is generated for you and the database has no public endpoint at all, which removes the most popular way a hobby project gets its data deleted by a stranger.
Scheduled messages and background work
Sooner or later the bot has to send something nobody asked for right now: a morning digest, a reminder, a nightly cleanup. There are two places to put that, and they fail differently.
A scheduler inside the bot process — an asyncio task, APScheduler, a loop with a sleep — is the least code. It is also correct only while exactly one copy of the process is running, because the timer lives in the process. That is usually true for a polling bot, which can only have one instance anyway, and stops being true the moment you scale or run a second copy against a staging token by accident. Two replicas mean two digests, sent a few milliseconds apart, to everyone.
A platform cron job is the other place: a separate container that starts on a schedule, runs one command and exits. It cannot double-fire because only one of it exists, it does not keep a scheduler awake inside your bot, and its output lands in its own log rather than in the middle of the message traffic. The cost is that it starts a fresh process, so it needs its own database connection and its own copy of the token. If the five fields of a cron expression are not muscle memory yet, that is the piece to read before you write 0 9 * * * and hope.
The same split applies to slow work inside a handler: anything taking seconds — rendering an image, calling a slow API — blocks the update loop while it runs, so push it onto a queue and let a second process handle it.
What 24/7 actually costs
Prices as published on 1 September 2026. A bot that polls is not a web service, which quietly removes most of the free tier from the table before you start.
Free tiers that sleep. Render's free plan spins a service down after fifteen minutes without traffic. A polling bot receives no inbound traffic at all, so it is not what those tiers are for, and a sleeping bot is simply an offline bot. Treat “free” here as “free while somebody is watching”.
A small VPS. The cheapest arithmetic, and the one where you own everything: the kernel updates, the restarts after a reboot, the Postgres backups. Budget an evening a month, and actually test that you can restore the database rather than assuming.
A container platform. Fly.io runs a 256 MB shared-cpu-1x machine for $2.02 a month, which is comfortably more than an idle aiogram bot needs, though you then operate the Postgres yourself on a second one. Render charges $7 a month for a background worker and $6 for a 256 MB managed Postgres, so around $13 for the pair. Granite is $9 a month for the project, including $15 of usage credits that the bot and its database both draw from.
Where Granite is the wrong answer. A hobby bot with no database — a poller, a link shortener, something that answers four commands — does not need any of that. It needs one always-on process, and a $2 machine or a small VPS will run it for a fraction of a $9 monthly subscription. Granite earns its price when the bot has a database beside it, a scheduled job or two, and someone who would rather not be the person who maintains Postgres. Below that line, we are the expensive option and there is no point pretending otherwise.
Deploying it
The shape below is Granite's, but it translates to any platform with a worker process type.
1. Create the resource as a worker, not a web app. Granite has three resource types — web-app, worker and cron-job — and a polling bot is a worker: it must always be running, and it has no HTTP port to expose. Choosing web-app would ask you for a port the bot does not have.
2. Point it at an image or a repository. Give it a Docker image from any registry — with credentials if that registry is private — and the platform watches the tag and redeploys when it moves; or connect a GitHub, GitLab or Bitbucket repository and it builds the Dockerfile above on every push. The Command field overrides the image's CMD, which is how one image runs both the bot and its cron jobs.
3. Add the secrets. BOT_TOKEN and DATABASE_URL go in as environment variables, stored encrypted with a separate key per application. Set them at application level and every resource sees them — which is what you want when the cron job needs the same token as the bot.
4. Create the database. Databases → Create database → PostgreSQL, pick the version and a node configuration, and the credentials appear on the database page once it goes green. There is no public endpoint: the bot reaches it over internal DNS, and you reach it from your own machine with novps port-forward database when you need psql.
5. Read the logs. The dashboard streams them and so does the CLI: novps resources logs follows in real time, --since 1h looks backwards, --search filters. This is where you discover the bot exited because BOT_TOKEN was empty, which is the first deployment’s most popular failure. To rehearse the whole stack locally first, running it with docker compose gets the bot and a Postgres onto your laptop in one file.
Whichever platform you land on, the requirement list is short and unusual for a Python project: one always-on process, one small database, no HTTP port. Python hosting compared puts seven platforms side by side on exactly that, including what each of them charges for a process that serves no requests.
Frequently asked questions
Does a Telegram bot need a public IP?
Not if it uses long polling. The bot makes outbound HTTPS requests to Telegram and nothing ever connects to it, so no public address, domain or certificate is involved. You only need a public HTTPS endpoint if you switch to webhooks, and then Telegram requires a valid certificate on port 443, 80, 88 or 8443.
Why does my bot stop when I close the terminal?
Because it was a child of that terminal session and went away with it. Nothing is broken; the bot simply has no supervisor. The fix is not nohup or screen, both of which die at the next reboot, but a process something else owns — a systemd unit on a VPS, or a worker resource on a container platform, restarted after a crash and after the host is replaced.
Can I run two instances of the same bot?
Not with long polling. Telegram allows one getUpdates consumer per token and answers 409 Conflict when a second one appears, so a second replica takes the bot down rather than scaling it. With webhooks you can put several instances behind one URL, but then every handler has to tolerate running twice and anything scheduled needs a single owner.
Webhook or polling for a small bot?
Polling. It removes the domain, the certificate and the inbound port from the list of things that can break, behaves identically on your laptop and in production, and the latency difference is invisible below serious volume. Revisit it when holding a connection open is a measurable cost, not before.
How much RAM does a Telegram bot need?
Less than the smallest tier of most platforms. An idle aiogram bot with a database pool sits in tens of megabytes, and 256 MB is comfortable for anything mostly forwarding text. The work decides the number, not the framework: image processing, PDF generation or a model loaded into the process will dominate long before aiogram does.