Telegram MTProto proxy with FakeTLS masking, configured entirely through environment variables
1.5K
Telegram MTProto proxy with FakeTLS masking, built from source from sleep3r/mtproto.zig and pinned to a release tag.
The container renders config.toml from environment variables at startup, so there is no
config file to generate, template, or mount. A static binary on debian:bookworm-slim,
no runtime dependencies beyond curl and CA certificates.
| Tag | Contents |
|---|---|
1.15.0 | Immutable — always this upstream release |
latest | Moves with each new release |
Platforms: linux/amd64, linux/arm64.
services:
mtproto:
image: andromedi4/mtproto.zig:1.15.0
container_name: mtproto-zig
restart: unless-stopped
environment:
MTP_TLS_DOMAIN: "rutube.ru" # domain the traffic is masked as
MTP_SECRET: "" # empty -> generated and persisted in the volume
MTP_PRINT_LINKS: "1" # print tg:// links to the log on startup
MTP_LINK_HOST: "proxy.example.com"
volumes:
- mtproto-state:/var/lib/mtproto
ports:
- "443:443"
volumes:
mtproto-state:
docker compose up -d
docker compose logs -f mtproto
With MTP_PRINT_LINKS=1 the log contains a ready-to-share tg://proxy?... link. Note that
the link embeds the secret, so treat that log as sensitive and turn the flag back off
once you have copied it.
MTP_TLS_DOMAIN cannot be changed after you hand out links — it is baked into the secret
clients use.
All are optional.
| Variable | Default | Description |
|---|---|---|
MTP_PORT | 443 | Listen port |
MTP_TLS_DOMAIN | rutube.ru | FakeTLS masking domain. Immutable once links are distributed |
MTP_SECRET | (auto) | 32-hex secret for user1. Empty → generated and persisted |
MTP_SECRET_FILE | — | Path to a file holding the secret (Docker/Swarm secrets) |
MTP_USERS | — | Multiple users: alice:HEX32,bob:HEX32. Overrides MTP_SECRET. An entry without : gets a random secret that is not persisted — see below |
MTP_USER_MAX_IPS | — | Concurrent unique-network quota per user: 4 for everyone, or alice:4,bob:2. Empty = unlimited (details) |
MTP_USE_MIDDLE_PROXY | true | MiddleProxy — required for media on non-Premium accounts |
MTP_METRICS | true | Prometheus endpoint on :9400 |
MTP_MASK | true | Forward active probes instead of dropping them |
MTP_FAST_MODE | true | Fast path for proxied traffic |
MTP_MAX_CONNECTIONS | 512 (proxy default) | [server].max_connections. Raised automatically when the WEB proxy is on (why) |
MTP_MASK_TARGET | — | Override the active-probe forward target |
MTP_MASK_PORT | 443 | Port of that backend |
MTP_MIDDLE_PROXY_NAT_IP | — | Host public IPv4 — fixes media behind Docker bridge NAT |
MTP_PUBLIC_IP | — | Host/IP advertised in generated links |
MTP_TAG | — | 32-hex promotion tag from @MTProxybot |
MTP_LINK_HOST | (autodetect) | Host shown in printed links — set it explicitly, autodetect from inside a container is unreliable |
MTP_PRINT_LINKS | 0 | 1 → print tg:// links (with secrets) to the log |
MTP_CONFIG | /etc/mtproto-proxy/config.toml | Rendered config path |
MTP_RENDER | auto | force → re-render even if a config already exists |
MTP_STATE_DIR | /var/lib/mtproto | Where the auto-generated secret persists |
MTP_ROLE | proxy | relay → run the WEB relay instead of the proxy (see below) |
MTP_WEB_ENABLED | false | Turn on WEB proxy. Needed by both the proxy and the relay |
MTP_WEB_ONLY | false | Answer MTProto for the relay and nobody else — every ee/dd link already handed out stops working (see below) |
MTP_WEB_DOMAIN | — | Hostname in tg://webproxy links. Immutable once handed out |
MTP_WEB_HOST / MTP_WEB_PORT | 127.0.0.1 / 8081 | Relay listener |
MTP_WEB_BACKEND | 127.0.0.1:MTP_PORT | Proxy the relay dials on every stream |
MTP_WEB_CLIENT_IP_HEADER | x-forwarded-for | Header carrying the real client IP (cf-connecting-ip behind Cloudflare) |
MTP_WEB_RELAY_SOURCES | — | IP literals of relays outside this container's netns |
Changing any variable takes effect on docker compose up -d — the container is recreated
and the config re-rendered.
Since 1.15.0 the container runs as uid 10001, not root. It still binds :443 — the
binary carries cap_net_bind_service, because Docker's default bounding set grants
NET_BIND_SERVICE to root only; an unprivileged process needs the capability on the file
itself.
Upgrading from 1.14.0 or earlier needs nothing if MTP_SECRET is set — the state
volume is never written in that case. If it is empty, the persisted secret lives in a
volume created by a root-era image and the new uid cannot read it. The container refuses
to start rather than mint a replacement, because a fresh secret would silently invalidate
every link already handed out:
mtproto-proxy: ERROR /var/lib/mtproto/user1.secret exists but is unreadable as uid 10001.
One-off fix on the host:
docker compose down
docker run --rm -v <volume>:/s --user 0:0 debian:bookworm-slim chown -R 10001:10001 /s
docker compose up -d
Fresh installs need no fix: a new volume inherits ownership from the image, where the
directory already belongs to mtproto.
MTP_SECRET=$(openssl rand -hex 16). Links stay stable across hosts./var/lib/mtproto — it survives recreation, but is tied to that volume. Mount the volume
or you will hand out new links after every docker volume rm.MTP_SECRET_FILE at a mounted Docker/Swarm secret.Persistence applies only to the single-user case. When MTP_USERS is set, any entry
written without a secret gets a freshly generated one on every start and it is never saved,
so those links break after each restart — silently. Give every user an explicit secret:
MTP_USERS: "alice:0123...,bob:4567..." # 32 hex chars each
Removing a user from MTP_USERS and running docker compose up -d revokes just that link.
Added upstream in 1.12.0. Caps how many distinct client networks may use one link at the same time:
MTP_USER_MAX_IPS: "4" # same cap for every user
# MTP_USER_MAX_IPS: "alice:4,bob:2" # ...or per user; anyone unlisted stays unlimited
What counts is a network — one IPv4 address, or one IPv6 /64 — not a socket, because a
single Telegram client already opens several of those ([access.user_max_conns] is the
knob for sockets). The /64 matters: SLAAC hands one prefix to a whole home LAN, and privacy
extensions rotate the host part inside it, so keying on the full address would burn the
quota in minutes.
Counting is concurrent, never historical: a network holds its slot only while it has a live connection, so users roam freely — they just cannot be in N+1 networks at once. The extra connection is refused, the client is not told why, and it keeps retrying.
Size it generously. One phone can legitimately need more than one slot:
idle_timeout_sec, so the abandoned network keeps its slot for up to ~2 minutes;3–4 per device is a sane floor. 1 will lock a user out of their own link.
What it is not: a defence against resale. There is no eviction — the first N networks win, so whoever the link was passed to can take the slots simply by connecting first. CGNAT also hides thousands of subscribers behind one IPv4, so a whole customer base on one carrier fits in a single slot. A link that really leaked needs a new secret.
With the WEB proxy, unless MTP_WEB_MASK_BACKEND is set, the relay hands every browser
to the proxy as 127.0.0.1. Those connections are exempt from the quota rather than
crowding into one shared slot — otherwise they would evict the user's real devices while
limiting nothing.
Three metrics appear, but only for users that have a quota (the others are not tracked
at all, and a 0 would read as "nobody connected" rather than "no limit"):
| Metric | Meaning |
|---|---|
mtproto_user_unique_ips_active | networks holding connections right now |
mtproto_user_unique_ips_max | the configured quota |
mtproto_user_ip_limit_refused_total | connections refused because the quota was full |
The counter earns its place: a gauge sitting at its maximum is the normal state of a user at quota, so it cannot distinguish "sized correctly" from "turning away a real device right now".
Read at startup — docker compose up -d applies a change, SIGHUP does not. A name that is
not in MTP_USERS produces a startup warning instead of being silently ignored.
Mount a config.toml at /etc/mtproto-proxy/config.toml and the entrypoint uses it as-is,
ignoring the MTP_* variables. Set MTP_RENDER=force to render from the environment anyway.
The two architectures ship different CPU baselines, and both choices are deliberate.
| Arch | Zig -Dcpu | AES |
|---|---|---|
amd64 | x86_64+aes+avx | Hardware — AVX shipped on Intel since Sandy Bridge and AMD since Bulldozer, both 2011 |
arm64 | baseline | Software — runs on any ARMv8 |
Hardware AES is off on arm64 on purpose. ARMv8 crypto extensions are absent on every
Raspberry Pi before the 5 (Pi 3 is Cortex-A53, Pi 4 is Cortex-A72), and a build that uses
them dies there with Illegal instruction inside std.crypto.aes.armcrypto on the first
TLS handshake. The cost is the AES backend is software-only warning at startup and
heavier CPU use for MiddleProxy video.
On arm64 hardware that does have the extensions (Graviton, Ampere, Pi 5), or on a pre-2011
x86 CPU that lacks AVX, rebuild from source with a different MTP_CPU — see below.
A fourth proxy type added upstream in 1.11.0. The client opens https://<domain>/ in a
hidden WebView and tunnels MTProto over a WebSocket from that page. Unlike FakeTLS — which
imitates a TLS session to someone else's domain — this is a genuine HTTPS session with a
real site that serves real HTML if you visit it in a browser.
Desktop 7.1+ only. Mobile clients have no such proxy type, so FakeTLS links stay primary.
TLS is not terminated by this image — it ships no certificates and no ACME client. Put
Traefik, nginx, or a CDN in front with a valid certificate for MTP_WEB_DOMAIN, forwarding
the client address in X-Forwarded-For; without that every WEB user reaches Telegram as the
terminator's own address.
The relay is a second container from this same image with MTP_ROLE=relay, sharing the
proxy's network namespace so it reaches the proxy over loopback — and the proxy sees the
relay as loopback, which is what makes it trusted (fake_tls_only refuses untrusted
relays). The relay then has no address of its own: port 8081 belongs to the proxy
container, so route the WEB domain to <proxy-container>:8081.
services:
mtproto:
image: andromedi4/mtproto.zig:1.15.0
environment:
MTP_USERS: "alice:0123...,bob:4567..." # pin them: the relay needs the same secrets
MTP_WEB_ENABLED: "true"
MTP_WEB_DOMAIN: "web.example.com"
web-relay:
image: andromedi4/mtproto.zig:1.15.0
depends_on: [mtproto]
network_mode: "service:mtproto"
environment:
MTP_ROLE: "relay"
MTP_WEB_ENABLED: "true"
MTP_WEB_DOMAIN: "web.example.com"
MTP_WEB_HOST: "0.0.0.0"
MTP_USERS: "alice:0123...,bob:4567..." # identical to the proxy's
MTP_METRICS: "false" # :9400 is already taken in the shared netns
Both roles render the same config from the same variables, so the users must match — pin the secrets or the two disagree on every restart.
Links appear alongside the regular ones under MTP_PRINT_LINKS=1, as
tg://webproxy?server=<domain>&secret=dd<32hex> — same per-user secret as the FakeTLS link
but prefixed dd instead of ee, and port 443 is implicit.
MTP_WEB_DOMAIN is as immutable as MTP_TLS_DOMAIN: the bridge capability is an HMAC over
the canonical hostname, so changing it invalidates every WEB link already issued.
Added upstream in 1.14.0, for deployments where the IP gets blocked the moment a Telegram
client connects to it directly. MTP_WEB_ONLY=true stops offering that door: the data
plane answers MTProto for the relay — recognised by the address accept() reports, never by
anything the client sends — and hands everyone else to the masking backend on exactly the
code path a wrong secret takes. A probe cannot tell the proxy from the site it fronts.
The price is not negotiable:
ee/dd link already handed out stops working, permanently. The links stay
well-formed; nothing on the other end answers them any more.MTP_WEB_MAX_SESSIONS becomes the real user ceiling: everyone now arrives via the relay.The entrypoint checks the preconditions before the proxy starts:
| Condition | What happens |
|---|---|
MTP_WEB_ENABLED not true | warning, MTP_WEB_ONLY ignored — otherwise the relay would not be trusted either and every connection would be masked |
MTP_WEB_DOMAIN empty | refuses to start: no WEB link can be built and direct MTProto is masked, so nothing could connect |
MTP_MASK=false | warning: refusals become bare closes, and under WEB-only that is 100% of public traffic — a cleaner probe signal than answering MTProto ever was |
Direct ee links are no longer printed under MTP_PRINT_LINKS=1 — a link that silently
never connects is worse than no link. Read at startup only; SIGHUP does not pick it up.
Watch mtproto_web_only_masked_total: these connections are masked rather than dropped, so
that counter is the only place they are visible at all.
If MTP_MASK_TARGET points at a third-party domain, turn it into a local cover site
first — under WEB-only it stops receiving the occasional probe and starts receiving all
public traffic. The proxy warns about this at startup; the warning is literal about what
counts as local (127.0.0.1, localhost, ::1), so a cover site addressed by container
name on the same host trips it harmlessly.
One WEB session can pin max_sessions × (max_streams + 1) proxy connections — 1056 at
the defaults — while [server].max_connections defaults to 512. So whenever
MTP_WEB_ENABLED=true, the entrypoint sizes max_connections to that worst case plus 512
for direct clients, and says so in the log:
mtproto-proxy: max_connections=1568 (WEB worst case 32x33 + 512 for direct clients; set MTP_MAX_CONNECTIONS to override)
Under MTP_WEB_ONLY the formula is the same but the +512 changes meaning — there are no
direct clients left, and the headroom covers the masked connections their refused
handshakes turn into (the log says for masked probes).
It tracks MTP_WEB_MAX_SESSIONS/MTP_WEB_MAX_STREAMS, so lowering those lowers it too, and
MTP_MAX_CONNECTIONS overrides the calculation entirely.
The proxy then applies its own RAM-safe clamp (~4 MiB per connection) and logs it:
warning(config): auto-clamping max_connections from 1568 to 1187 (host has 7936 MiB RAM, ...)
That keeps a small host out of swap, but it can land below the WEB worst case — and then
WEB sessions can eat the whole budget out from under direct clients. Raising
MTP_MAX_CONNECTIONS does not help (the clamp cuts it back); lower MTP_WEB_MAX_SESSIONS /
MTP_WEB_MAX_STREAMS instead. Rule of thumb: if the clamped number is below
sessions × (streams + 1), the WEB caps are too big for that machine.
With MTP_METRICS=true (the default), Prometheus metrics are served on :9400. Keep that
port on loopback rather than publishing it:
ports:
- "127.0.0.1:9400:9400"
A matching healthcheck:
healthcheck:
test: ["CMD-SHELL", "curl -sf -o /dev/null --max-time 3 http://127.0.0.1:9400/metrics"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
The proxy speaks raw FakeTLS, so a fronting proxy must do TLS passthrough and route by
SNI — terminating TLS breaks it. The client's SNI equals MTP_TLS_DOMAIN, so the routing
rule must match that exact name. Drop the 443 port mapping and put the container on the
proxy's network.
Traefik:
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik_net"
- "traefik.tcp.routers.mtproto.rule=HostSNI(`proxy.example.com`)"
- "traefik.tcp.routers.mtproto.entrypoints=websecure"
- "traefik.tcp.routers.mtproto.tls.passthrough=true"
- "traefik.tcp.routers.mtproto.service=mtproto-svc"
- "traefik.tcp.services.mtproto-svc.loadbalancer.server.port=443"
On a Docker bridge network the proxy may fail to detect its own outbound IP, which breaks
media for non-Premium users. Set MTP_MIDDLE_PROXY_NAT_IP to the host's public IPv4, or
run with network_mode: host (then drop ports). Telegram voice calls are not supported
by the proxy.
MTProto proxies hold many concurrent connections:
pids_limit: 65535
ulimits:
nofile:
soft: 65535
hard: 65535
nproc: 65535
docker build \
--build-arg MTP_VERSION=1.15.0 \
--build-arg MTP_CPU=baseline \
-t mtproto-zig .
MTP_CPU takes any Zig CPU spec: baseline for portable software AES anywhere,
baseline+aes for arm64 with crypto extensions, x86_64+aes+avx for x86. If the running
CPU is older than the chosen baseline, the process aborts with Illegal instruction at
startup.
Content type
Image
Digest
sha256:87efa7285…
Size
36.4 MB
Last updated
11 days ago
docker pull andromedi4/mtproto.zig