Updated Nov 5, 20266 min read

How to Deploy a Telegram Bot to AWS

A webhook-based Telegram bot in TypeScript with Postgres, verified with Telegram's secret token header and deployed to your own AWS account.

Most Telegram bots start as a script that calls getUpdates in a loop. That works on a laptop, and it is awkward on AWS: the process has to run all the time, it cannot be scaled to two instances because Telegram rejects a second concurrent getUpdates request, and a restart in the middle of a batch can drop messages. Telegram's other mode, webhooks, fits the cloud better. Telegram sends each update as an HTTPS POST to your endpoint, and your bot becomes an ordinary API.

This guide builds a small notes bot with a webhook, stores the notes in Postgres and deploys it to AWS. The bot saves a note with /note buy milk and lists the last ten with /list.

Quick start with Encore

Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. The bot's database, its two secrets and the webhook endpoint are declared in code, run locally with encore run, and deploy to Fargate, RDS and Secrets Manager in your own AWS account. The table is a single migration:

-- bot/migrations/1_create_notes.up.sql CREATE TABLE notes ( id BIGSERIAL PRIMARY KEY, chat_id BIGINT NOT NULL, text TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX notes_chat_id_idx ON notes (chat_id, created_at DESC);

The webhook is a raw endpoint, which gives direct access to the request headers and body:

// bot/bot.ts import { api } from "encore.dev/api"; import { secret } from "encore.dev/config"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import log from "encore.dev/log"; const db = new SQLDatabase("bot", { migrations: "./migrations" }); const botToken = secret("TelegramBotToken"); // The secret_token you pass to setWebhook; Telegram echoes it in a header. const webhookSecret = secret("TelegramWebhookSecret"); interface Update { update_id: number; message?: { chat: { id: number }; text?: string }; } async function reply(chatId: number, text: string) { const res = await fetch(`https://api.telegram.org/bot${botToken()}/sendMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: chatId, text }), }); if (!res.ok) log.error("sendMessage failed", { status: res.status }); } async function handle(update: Update) { const msg = update.message; if (!msg?.text) return; const chatId = msg.chat.id; if (msg.text.startsWith("/note ")) { const text = msg.text.slice("/note ".length).trim(); await db.exec`INSERT INTO notes (chat_id, text) VALUES (${chatId}, ${text})`; await reply(chatId, "Saved."); } else if (msg.text === "/list") { const lines: string[] = []; for await (const row of db.query<{ text: string }>` SELECT text FROM notes WHERE chat_id = ${chatId} ORDER BY created_at DESC LIMIT 10`) { lines.push(`- ${row.text}`); } await reply(chatId, lines.length ? lines.join("\n") : "No notes yet."); } else { await reply(chatId, "Send /note <text> to save a note, or /list to see them."); } } // Raw endpoint: Telegram posts the update as JSON and only needs a 2xx back. export const webhook = api.raw( { expose: true, method: "POST", path: "/telegram/webhook" }, async (req, resp) => { if (req.headers["x-telegram-bot-api-secret-token"] !== webhookSecret()) { resp.writeHead(401).end(); return; } const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const update = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Update; try { await handle(update); } catch (err) { // Answer 200 anyway so Telegram does not redeliver the same update forever. log.error(err, "failed to handle update", { updateId: update.update_id }); } resp.writeHead(200).end(); }, );

The secret token check rejects any request that did not come from Telegram, since the webhook URL is public, and the handler answers 200 even when handling fails, because Telegram retries an update that gets an error response, and a bug in one command should not turn into the same message arriving again and again. Errors still end up in the logs and traces.

Set the secrets for each environment, with the bot token from BotFather and a random string of your own as the webhook secret:

~/notes-bot
$ encore secret set --type prod TelegramBotToken$ encore secret set --type prod TelegramWebhookSecret$ git push encore

After the deploy, register the webhook with Telegram's setWebhook method, passing the same secret as secret_token:

~/notes-bot
$ curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \ -d url=https://prod-notes-bot-xxxx.encr.app/telegram/webhook \ -d secret_token=$TELEGRAM_WEBHOOK_SECRET

Encore provisions RDS for the database, stores the secrets in Secrets Manager and runs the service on Fargate behind a load balancer with HTTPS, all in your own AWS account. Locally, encore run starts Postgres and the endpoint, and you can test the webhook by posting a sample update with the header set.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

With the bot's infrastructure in code, a coding agent can add a new command with its own table, run it locally and open a pull request. Cloud access sits with Encore's IAM role, not the agent.

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

Manual path: Fargate, RDS and Secrets Manager

The same bot runs on AWS without Encore, with more pieces to wire together. Replace the declarations with a pg pool reading DATABASE_URL, read both secrets from environment variables, and serve the webhook from a small HTTP server such as Express or Fastify. Then create:

  • an RDS for PostgreSQL instance in private subnets, and run the migration against it,
  • Secrets Manager entries for the token, the webhook secret and the database URL,
  • an ECR repository and a Docker image of the bot,
  • an ECS service on Fargate with a task definition that maps the secrets to environment variables, and an execution role that can read them,
  • an Application Load Balancer with an ACM certificate, since Telegram only sends webhooks to HTTPS endpoints on ports 443, 80, 88 or 8443.

Lambda behind a function URL is a cheaper option for low-traffic bots, with the trade-off that the database connection needs a proxy such as RDS Proxy once traffic picks up, since each concurrent invocation opens its own connection.

Good to know

getUpdates and a webhook cannot be used at the same time, so stop any polling process before registering the webhook. getWebhookInfo shows the last error Telegram saw when delivering, which is the first place to look if the bot goes quiet. If the endpoint is down, Telegram keeps undelivered updates for up to 24 hours.

How to choose

Use Encore when you want the bot, its database and secrets in one codebase, deployed to your own AWS account with a local environment that matches. Build the manual stack when the bot has to fit into existing Terraform for a larger system. For heavier work triggered by commands, such as generating a report, move it to a Pub/Sub subscriber so the webhook answers quickly.

Frequently asked questions

Should a Telegram bot on AWS use polling or webhooks?

Use webhooks. A webhook bot only does work when Telegram posts an update, so it runs behind a normal HTTPS endpoint and scales like any API. Polling needs a process that is always running, and only one getUpdates request per bot can be active at a time, so the work cannot be spread across several instances.

How do I verify that a webhook request really came from Telegram?

Pass a secret_token when you call setWebhook. Telegram then sends it in the X-Telegram-Bot-Api-Secret-Token header of every update, and your endpoint rejects any request where the header does not match.

What is the quickest way to deploy a Telegram bot to my own AWS account?

With Encore, the webhook is an api.raw endpoint, the bot token is secret("TelegramBotToken") and the database is new SQLDatabase("bot", { migrations: "./migrations" }). Pushing the code deploys it to Fargate with RDS and Secrets Manager in your AWS account, and you point setWebhook at the resulting URL.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders