local bot api

self-host telegram's bot api server when file limits or network control matter.

when to use it

for most bots the cloud api is the right default — a local server is one more process to run, update and monitor. run one when you hit its limits:

needcloud apilocal server
upload filesup to 50 MBup to 2000 MB
download filesup to 20 MBno limit
webhookshttps only, ports 443/80/88/8443plain http, any port, up to 100000 connections
trafficthrough telegram's cloudstays in your private network

the file and webhook advantages need the server started in local mode (the TELEGRAM_LOCAL flag below) — without it a self-hosted server keeps the cloud limits. local mode also changes how you receive files; see files in local mode.

it's another service to run. tdlib (what the server is built on) keeps an in-memory cache proportional to traffic and chat count — budget at least 512 MB–1 GB of RAM for a small-to-medium bot, more under heavy media traffic, plus disk for /var/lib/telegram-bot-api (downloaded files accumulate there; see production notes).

credentials

you need BOT_TOKEN from @BotFather and TELEGRAM_API_ID/TELEGRAM_API_HASH — application credentials from my.telegram.org. treat all three as secrets.

run the server

yaebal ships its own server image, built from tdlib/telegram-bot-api source in the yaebal repo and rebuilt automatically when upstream moves. multi-arch (amd64/arm64), runs as non-root, healthcheck included. also on docker hub as neverlane/telegram-bot-api.

docker-compose.yml
services:
  telegram-bot-api:
    image: ghcr.io/neverlane/telegram-bot-api:latest
    restart: unless-stopped
    environment:
      TELEGRAM_API_ID: ${TELEGRAM_API_ID}
      TELEGRAM_API_HASH: ${TELEGRAM_API_HASH}
      TELEGRAM_LOCAL: "1"
    volumes:
      - telegram-bot-api-data:/var/lib/telegram-bot-api
    ports:
      # bind to localhost only — the server has no auth of its own beyond the
      # bot token embedded in the request path. if the bot container shares
      # this compose network, drop the ports mapping entirely and reach it as
      # http://telegram-bot-api:8081 instead.
      - "127.0.0.1:8081:8081"
      - "127.0.0.1:8082:8082" # stats — also keep private

volumes:
  telegram-bot-api-data:

latest tracks upstream master; every build is also tagged sha-<12> with the upstream commit it was built from — pin that in production.

switch a bot token

a bot token is logged in either to the cloud api or to your server, never both. call logOut once, while apiRoot still points at the cloud api:

migrate.ts
import { createBot } from "yaebal";

// while apiRoot still points at the cloud api:
const bot = createBot(process.env.BOT_TOKEN!);
await bot.api.call("logOut", {});
// the local server accepts the token immediately. going back to the
// cloud api is locked for 10 minutes after logOut.

connect yaebal

apiRoot is the root before /bot<token>/method. nothing else changes — polling and handlers work as before.

bot.ts
import { createBot } from "yaebal";

const bot = createBot(process.env.BOT_TOKEN!, {
  apiRoot: "http://localhost:8081",
});

moving back to the cloud api

the same logOut call works in reverse — call it while apiRoot still points at your server, wait out the 10-minute lock, then point apiRoot back at the cloud default (or drop the option entirely).

migrate-back.ts
import { createBot } from "yaebal";

// while apiRoot still points at the local server:
const bot = createBot(process.env.BOT_TOKEN!, { apiRoot: "http://localhost:8081" });
await bot.api.call("logOut", {});
// wait 10 minutes, then point apiRoot back at the cloud default (or omit it).

files in local mode

with TELEGRAM_LOCAL: "1" the server no longer serves file bytes over http: getFile returns an absolute path on the server's disk, and bot.api.fileUrl() stops being a downloadable link. share the data volume with the bot container and read the file directly:

download.ts
import { readFile } from "node:fs/promises";
import { createBot } from "yaebal";

const bot = createBot(process.env.BOT_TOKEN!, { apiRoot: "http://localhost:8081" });
const fileId = "AgACAgIAAx...";

const file = await bot.api.call<{ file_path?: string }>("getFile", { file_id: fileId });
// with TELEGRAM_LOCAL=1 file_path is an absolute path on the server's disk —
// mount the data volume into the bot container at the same path and read it.
const bytes = await readFile(file.file_path!);

without local mode bot.api.fileUrl() keeps working the cloud way — but so do the cloud file-size limits.

bot.api.downloadFile() doesn't work in local mode. it fetches fileUrl(file_path) under the hood, which is exactly the link that stops resolving once file_path becomes a server-disk path — it throws an HttpError rather than silently returning the wrong bytes. use the getFile + readFile pattern above instead.

webhooks

in local mode the server accepts plain http webhook urls on any port — handy when the bot and the server share a compose network (setWebhook to http://bot:3000/webhook, no tls termination needed). the handler side is the same as always: webhooks & deploy.

production notes

  • pin the image by its sha-<12> tag instead of latest.
  • keep the bot and the server in the same private network and never expose port 8081 publicly — requests are authenticated only by the bot token.
  • port 8082 answers with server stats — useful for monitoring and the container healthcheck, keep it private too.
  • do not log file urls: they contain the bot token.
  • mount persistent storage for /var/lib/telegram-bot-api — the server keeps downloaded files there, so watch disk usage and clean up old files.
  • serve downloads through your own authenticated endpoint if users need links.