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:
| need | cloud api | local server |
|---|---|---|
| upload files | up to 50 MB | up to 2000 MB |
| download files | up to 20 MB | no limit |
| webhooks | https only, ports 443/80/88/8443 | plain http, any port, up to 100000 connections |
| traffic | through telegram's cloud | stays 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.
/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.
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:
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.
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).
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:
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 oflatest. - 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.