Steer your Docker containers from Telegram — your phone is the helm.
Written in TypeScript, run on the Bun runtime (no build step — Bun executes the .ts entry directly).
Control your homelab's Docker containers from Telegram — list, view logs/stats, start/stop/restart, and watch logs for patterns that ping you when they appear — with a security model designed around the fact that a Telegram bot is an internet-reachable remote-control surface.
Your phone / work machine (Telegram app)
│
▼
Telegram servers ◄── relay
│
▼
bot (long polling — dials OUT, no inbound ports) ┐
│ internal docker network only │ both run ON the homelab
▼ │
socket-proxy (least-privilege) ┘
│
▼
Docker daemon (/var/run/docker.sock)
The bot runs on the homelab, next to Docker. Your work machine is just a Telegram client — it needs no network path to the homelab, and the homelab opens no inbound ports (long polling is outbound-only, so it works behind home NAT with nothing forwarded).
Two controls are your root credentials — get these right and the rest is defense in depth:
- Bot token — functionally equivalent to control of your Docker host. Store in
.env, never in git. - User-ID allowlist (
ALLOWED_USER_IDS) — the bot silently drops any message from an ID not on the list, and refuses to start if the list is empty (fail-closed).
Layered on top:
- Least-privilege socket proxy. The bot never touches
/var/run/docker.sock. It talks tolscr.io/linuxserver/socket-proxy, which exposes only: container list/inspect/logs/stats (CONTAINERS=1) and start/stop/restart (ALLOW_START/STOP/RESTARTS=1).POST=0stays off, so container create, exec, image build/pull, volumes, and networks are all blocked at the proxy. Even a fully compromised bot cannot create a privileged container or exec into one.- Why this image and not
tecnativa/docker-socket-proxy? On the original,ALLOW_RESTARTSdoesn't work unless you also setPOST=1— which re-opens create/exec. The LinuxServer fork gates each lifecycle POST independently while keepingPOST=0.
- Why this image and not
- Network air-gap. The proxy sits on an
internal: truenetwork with no route to the internet. Only the bot has an egress network (to reach Telegram). The proxy can never phone home. - Confirmation step. Start/stop/restart require a second tap (
✅ Yes). - Rate limiting. Per-user sliding window, on top of the allowlist.
- Audit log. Every privileged action emits a JSON line (
audit: true) with user ID, action, target, and result — captured by the Docker log driver. - Hardened bot container. Runs as non-root,
cap_drop: ALL,no-new-privileges, read-only root filesystem. The only writable path is a dedicatedmonitorsnamed volume at/data(owned by the unprivilegedbunuid), where log-monitor definitions persist — nothing else on disk is mutable.
First, get your two secrets:
- Create the bot. Message @BotFather →
/newbot→ copy the token. - Find your user ID. Message @userinfobot; it replies with your numeric ID.
Then pick how to run it.
The bot ships as a published multi-arch image — fuongz/telehelm (linux/amd64 + linux/arm64) — so you don't need to clone the repo or build anything. Grab the compose file and the env template straight from GitHub, fill in the two secrets, and bring it up:
mkdir telehelm && cd telehelm
# Compose stack + env template, pulled raw from the repo
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/fuongz/telehelm/main/docker-compose.yml
curl -fsSL -o .env https://raw.githubusercontent.com/fuongz/telehelm/main/.env.example
# Edit .env: set BOT_TOKEN and ALLOWED_USER_IDS (comma-separated)
nano .env
# Pull the published image and start (compose reads secrets from .env)
docker compose up -dUpdate later with docker compose pull && docker compose up -d. In Telegram, open your bot and send /ps.
Pin a version instead of tracking
latest: editdocker-compose.ymland setimage: fuongz/telehelm:1.0.0(see tags on Docker Hub).
Portainer: create a stack, paste the compose file (or point it at the raw URL above), and define
BOT_TOKEN/ALLOWED_USER_IDSin the stack's Environment variables section instead of a.envfile.
If you've cloned the repo (e.g. to develop or build your own image):
cp .env.example .env
# edit .env: set BOT_TOKEN and ALLOWED_USER_IDS (comma-separated)
docker compose up -dTo run your own locally built image rather than the published one, build it first with make build (tags fuongz/telehelm:latest locally, which compose then uses) — see Building & publishing.
You need Bun ≥ 1.1 and a reachable socket proxy.
bun install
bun run dev # watch mode; or `bun start`
bun run typecheck # tsc --noEmitPoint the bot at your proxy with DOCKER_PROXY_HOST / DOCKER_PROXY_PORT (defaults: socket-proxy:2375). The default monitors path (/data/monitors.json) won't be writable outside Docker — set MONITORS_FILE to a local path (e.g. ./monitors.json) if you want monitors to persist during local dev.
A Makefile wraps the Docker build/push flow. The image version is read from package.json, so that's the single source of truth.
make build # local single-arch image (loaded into Docker, no push)
make publish # multi-arch (amd64 + arm64) build, push :<version> + :latest
make bump-patch # bump version in package.json (also bump-minor / bump-major)
make version # print the version that will be published
make help # list all targetsTypical release: make bump-patch && make publish. First publish requires make login (use a Docker Hub Personal Access Token as the password). Override the target repo or platforms inline, e.g. make publish IMAGE=youruser/yourname PLATFORMS=linux/amd64.
/ps— list all containers; tap one to open its menu.- Container menu: 📄 Logs (last 100 lines), 📊 Stats (CPU/mem, sampled over 1s for accuracy), 🔔 Monitor (regex log watches — see below), and
▶️ Start / ⏹️ Stop / 🔄 Restart (with confirmation). /watches— list every active log monitor across all containers./help— command summary.
A log monitor watches one container's output for lines matching a regex and pings you on Telegram when new matches appear. Each monitor polls on its own interval and only ever inspects logs emitted since its last check, so a steady pre-existing line never re-fires — you're alerted only on genuinely fresh matches.
Set one up entirely from Telegram:
/ps→ tap a container → 🔔 Monitor → ➕ Add monitor.- Reply with the interval (seconds) followed by the regex. Optional extra lines tune it:
30 ERROR|panic|fatal ignore: healthcheck|debug cooldown: 600 min: 5 multiline: ^\d{4}-\d{2}-\d{2} restart: onignore:— a second regex; lines matching it are skipped (silence known-noisy output).cooldown:— minimum seconds between alerts for this monitor (defaults toMONITOR_DEFAULT_COOLDOWN).min:— only alert if at least N matches occur in a single check; the check interval is the effective window, so a lone transient line stays quiet while a burst fires.multiline:— a "firstline" regex marking the start of a log block. Lines that don't match it are folded into the preceding block, so a stack trace becomes one alert instead of one per line. The match/ignore regexes then test the whole block.restart:—on(ortrue/yes/1) makes the monitor restart the container whenever it alerts. Off by default. The restart fires from inside the same threshold + cooldown gate as the alert, so it runs at most once per cooldown window — never in a tight loop. The preview screen flags this before you confirm.
- The bot shows a dry-run preview — what the pattern would have matched in the recent log tail — then a ✅ Create / ❌ Cancel choice before anything is saved.
- Manage existing monitors from the same view: ⏸️ Pause /
▶️ Resume and 🗑️ Delete.
A regex monitor catches bad output. A silence watch catches the opposite failure: a container that stops producing logs — wedged on a failed reconnect, a hung request, or a stalled loop. It pings you when no new logs have appeared for longer than a threshold you set.
/ps→ tap a container → 🔔 Monitor → 🔕 Silence watch.- Reply with the silence threshold in seconds; optional extra lines tune it:
300 interval: 60 cooldown: 600 restart: on- threshold (line 1) — alert if no new logs arrive for this many seconds.
interval:— how often to check; defaults tomin(60, threshold)so silence is caught promptly without polling more often than the threshold.cooldown:— minimum seconds between alerts (same anti-storm gate as regex monitors).restart:—onto auto-restart the container when it goes silent — direct remediation for a stuck service. Off by default, gated by the cooldown so it can't restart-loop.
- Only fires while the container is running — a stopped container is quiet on purpose, not stuck. When logs resume, you get a ✅ Logs resumed recovery message.
Both kinds appear together in the container's monitor list and in /watches.
Details:
- Matching is case-sensitive (the pattern is compiled as
new RegExp(pattern)with no flags) and tested per log line. - The interval is bounded by
MONITOR_MIN_INTERVAL(default5s, protects the Docker socket from aggressive polling) andMONITOR_MAX_INTERVAL(default86400s). - Cooldown / anti-storm. After an alert fires, further matches are counted but not sent until the cooldown elapses; the next alert notes how many were suppressed. This stops a fast-flapping line from flooding the chat.
- Threshold debounce (
min:). Require N matches within a single check before alerting, so a one-off transient is ignored and only a real burst pages you. - Multi-line matching (
multiline:). Group stack traces and other multi-line records into a single alert via a firstline regex, instead of one alert per line. - Auto-restart (
restart: on). Optionally restart the watched container when a monitor alerts — automatic remediation for a crash-looped or wedged service. Gated by the same threshold + cooldown as the alert (so it can't restart-loop), audited asmonitor_autorestart, and the outcome is reported back to the chat. - Restart catch-up. The last-checked timestamp is persisted, so on startup each monitor immediately scans the gap since the bot was last up — you don't miss (or wait a full interval for) matches that occurred during downtime.
- Notifications cap at the first 10 matching lines per check to stay within Telegram's message limits.
- A monitor that fails its check 5 times in a row (e.g. the container was removed) auto-disables and tells you why.
- Bounded by design. A check won't start while the previous one is still running (no stacking on a slow Docker call); the total monitor count is capped (
MONITOR_MAX); each log line/block is length-capped (MONITOR_MAX_MATCH_LEN) before matching and obviously catastrophic patterns (e.g.(a+)+) are rejected at add time, to bound regex backtracking. - Audited. Creating, pausing/resuming, deleting, auto-disabling, and auto-restarting a monitor each emit an
auditlog line with the acting user (systemfor automatic actions). - Definitions persist to
/data/monitors.jsonon themonitorsvolume, so they survive bot restarts and updates. If that path can't be written the bot falls back to in-memory monitors and logsmonitor_persist_disabled.
| Action | Allowed |
|---|---|
| List / inspect containers | ✅ |
| View logs, stats | ✅ |
| Watch logs for a regex, get alerts | ✅ |
| Alert when a container stops logging (silence/heartbeat) | ✅ |
| Start / stop / restart | ✅ |
| Create / run new containers | ❌ blocked at proxy |
exec into a container |
❌ blocked at proxy |
| Pull / build images, manage volumes/networks | ❌ blocked at proxy |
docker_proxy_unreachablein logs — the proxy didn't start, or the socket isn't mounted. Checkdocker logs telehelm-socket-proxy.- Bot doesn't respond at all — your user ID isn't in
ALLOWED_USER_IDS(unauthorized messages are dropped silently; checkdocker logs telehelm-botforunauthorizedlines), orBOT_TOKENis wrong. 409 Conflicton launch — another instance is already polling the same token. Run only one.- Logs look garbled — handled for non-TTY containers via frame de-multiplexing and ANSI stripping; open an issue if a specific container still misbehaves.
If you later want richer control (e.g. exec, image management), flip the corresponding proxy flag — but understand each one widens the blast radius. EXEC=1 in particular is close to giving the bot host root. Add an extra confirmation tier before enabling anything beyond lifecycle.
Issues and PRs are welcome. See CONTRIBUTING.md for the workflow and the bar applied to anything that touches the security model, and CODE_OF_CONDUCT.md.
Found a vulnerability? Please report it privately — see
SECURITY.md. Don't open a public issue, and never paste your
BOT_TOKEN anywhere.