The Telegram Bot API server, built by us from pinned sources.
One container holds the tokens of every bot on the host and sees all of their messages. This is where what goes into it is decided.
The shared server on WS04 was a third-party image pinned by digest in July
2025 and never moved. It was still Bot API 7.11 in September 2026 — nearly two
years behind — and answered 404 method not found to every method Telegram
had shipped since. A bot written against 10.3 went quiet for a day without
producing a single error that said so.
Nothing about that was the image maintainer's fault. A pin that nobody moves rots whoever built it. What changed here is ownership: the sources are pinned to an exact upstream commit, the build is a workflow anyone can read, and the version the binary reports is checked against the version we think we pinned — twice, once at build time and once against the pushed image.
A server refuses to say anything without a valid token -- an invalid one gets
401 for every method, including ones that do not exist -- so a server cannot be
interrogated anonymously. deploy/probe.sh uses a bot that serves nothing for
exactly this: it can be pointed at an untested server without unplugging
anything that works.
./probe.sh # the default set, against the stack's server
./probe.sh http://other:8081 sendRichMessage # one method, another serverStore that bot's token as PROBE_BOT_TOKEN in the stack's .env. Only ask for
methods that cannot act on an empty body: a probe is a real call, so getMe
would simply run, deleteWebhook would drop a webhook, and logOut would
detach the bot for ten minutes.
Without this, verifying a new server means moving a real bot onto it -- and
moving a bot means logOut on the server it leaves, which is a one-way step.
Telegram's version is the server's; v<bot api>-<build> is this repository's,
because what is packaged around that server -- base image, entrypoint, labels,
the pinned upstream commit within one Bot API version -- changes on its own
schedule. :10.3 moves on every rebuild and :commit-<sha> names the upstream
commit, so neither is a thing to roll back to. A release names one digest that
was built and verified, and does not rebuild it. See
docs/versioning.md.
The server does not log its version unless verbosity is raised high enough to also log every second, so the version lives in the image instead:
docker inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' <image>
docker exec <container> telegram-bot-api --version # writes to stderrA bot can also ask the running server directly, which is the answer that
actually matters: whether it implements the methods that bot needs. See
Client.Preflight in FreshLabDev/tg.
tdlib/telegram-bot-api e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1 2026-08-25
└─ td (submodule) bc9c263e2bfee06aaab41e82db51a103376030bc
Bot API 10.3
Both live in Dockerfile as build arguments. Changing BOT_API_COMMIT
without changing EXPECTED_BOT_API fails the build if the commit declares a
different version, so a bump cannot ship quietly under the old number.
The server takes its credentials from the environment, never from arguments,
so they stay out of ps and out of every log line:
docker run --rm \
-e TELEGRAM_API_ID -e TELEGRAM_API_HASH \
-e TELEGRAM_LOCAL=1 \
-v ./data:/var/lib/telegram-bot-api \
ghcr.io/freshlabdev/telegram-bot-api@sha256:...| Variable | Default | What it does |
|---|---|---|
TELEGRAM_API_ID, TELEGRAM_API_HASH |
— | Required. From my.telegram.org |
TELEGRAM_LOCAL |
0 |
1 enables local mode: no download size limit, uploads to 2000 MB, and getFile answers with an absolute path |
TELEGRAM_WORK_DIR |
/var/lib/telegram-bot-api |
Working directory: one subdirectory per bot, named after that bot's token, holding its td.binlog |
TELEGRAM_FILES_DIR |
unset | Media directory (Bot API 10.3+). Set it to keep media out of the working directory |
TELEGRAM_TEMP_DIR |
/tmp/telegram-bot-api |
Temporary files |
TELEGRAM_HTTP_PORT |
8081 |
API port |
TELEGRAM_STAT_PORT |
unset | Statistics port. Reports uptime, bot count and memory — not the Bot API version |
TELEGRAM_VERBOSITY |
unset (server default 0) |
Log level. The default is FATAL-only: the server stays silent even when it fails. 1 adds errors and is what the deployment uses. 2 also prints the startup banner — and a CPU-usage line every second |
TELEGRAM_MAX_CONNECTIONS, TELEGRAM_MAX_WEBHOOK_CONNECTIONS, TELEGRAM_PROXY |
unset | Passed through |
The server binds as root and then drops to uid 101, so files it writes are
owned by 101:101. A bot container that reads them should run as that uid —
and it must be able to delete them, because a local server never reclaims a
file it produced.
--local is why this server exists, and it changes two things a bot has to
handle:
getFilereturns an absolute path on the server's own filesystem, and the/file/bot<token>/…route answers 404. The directory has to be mounted; it is not a download.- With
TELEGRAM_FILES_DIRset, media lives there instead of in the working directory. Mount a bot<files dir>/<its token>and it gets its own media and nothing else — no session state, its own or anyone's.
Never mount the parent of either directory into a bot. Both hold one subdirectory per bot, each named after that bot's full token.
A token is logged in on one server at a time; upstream is explicit that a bot logged in on two has no guarantee of receiving all updates. So:
- Stop the bot.
logOuton the server it is leaving.- Create its media directory here before it starts:
mkdir -p <files dir>/<token> && chown 101:101 <files dir>/<token>. Docker would otherwise create the missing bind source asroot, and then neither the server nor the bot could write in it. - Point it at this one and mount that directory.
- Start it.
Leaving for https://api.telegram.org works the same way, with a ten-minute
cooldown before the cloud server accepts the token again.
Our files are Apache-2.0. The server they build is Telegram's, under BSL-1.0, fetched at build time — see NOTICE.