Challenge Accepted: Self-Hosting a Telegram Video Bot with n8n and yt-dlp

By Anas Semesmieh · June 14, 2026 · Homelab, Docker, Self-Hosting, Automation

A friend mentioned offhand that he’d love a tool that lets you send a video link to a chat and get the file back — no browser, no app, no ads. I said “challenge accepted” and went quiet for a day. This is what came out of it.

The result: a self-hosted Telegram bot that accepts any URL supported by yt-dlp, presents a quality picker (480p / 720p / 1080p / audio only), downloads the file on a dedicated LXC, and either sends it directly in chat or returns a signed expiring download link for larger files. Everything runs on my homelab. Nothing touches a third-party service except the Cloudflare tunnel for ingress.

The Stack

CT105 is the dedicated container for this — 4 vCPU, 8 GB RAM, 100 GB local NVMe on Proxmox, sitting at 192.168.20.75. Four Docker services run on it, each with a single responsibility:

Service Image Port Role
telegram-bot-api aiogram/telegram-bot-api 8081 Local Bot API server (bypasses Telegram’s 50 MB upload cap)
ytdlp-worker custom FastAPI 8090 Runs yt-dlp + ffmpeg, exposes POST /download and POST /publish
files-api custom FastAPI 8088 Serves files via HMAC-SHA256 signed, expiring URLs
n8n n8nio/n8n 5678 Workflow orchestrator — the glue between everything

The local Bot API server is the key architectural choice. Telegram’s public API caps file uploads at 50 MB. Running the server locally removes that cap for outgoing files, which matters a lot for 1080p video. The tradeoff is that Telegram still receives the file — you just bypass the upload restriction because the local server handles the transfer to Telegram’s infrastructure differently.

n8n is the orchestrator. The whole point of using it here is to avoid writing glue code. The download logic, the delivery decision, the error handling — all of it lives in a visual workflow rather than a custom Python service that I’d have to maintain separately.

How It Works

The user flow is straightforward, but there are two distinct phases that happen at different times:

User sends URL | v n8n: Parse webhook → check allowlist → validate URL hostname | v Send inline keyboard: [480p] [720p] [1080p] [Audio] | v (user taps a button) Telegram callback query → n8n: Answer callback + send "Downloading..." | v POST /download to ytdlp-worker (yt-dlp + ffmpeg runs here) | / \ ≤50MB >50MB | | Send file POST /publish to NAS → GET signed link → Send link to user directly

For files under 50 MB, the worker returns the local path and n8n sends it directly via the local Bot API server. For anything larger, the worker publishes it to the NAS and the files-api generates a signed URL that expires after a set window. The user gets a download link instead.

Here’s the bot in action:

Wiring the Edges

Three infrastructure pieces connect the bot to the outside world:

AdGuard DNS rewritehook.semesmieh.com and n8n.semesmieh.com resolve internally to CT102 (Caddy) at 192.168.20.71. The same names resolve externally via Cloudflare.

Caddy on CT102 — reverse-proxies hook.semesmieh.com and n8n.semesmieh.com to http://192.168.20.75:5678, with automatic TLS via the Cloudflare DNS plugin. The Caddyfile entries are minimal — one reverse_proxy directive each.

Cloudflare tunnel — each public hostname points directly at http://192.168.20.75:5678. This matters: early in the build I had the tunnel pointing at https://192.168.20.71 (Caddy). That caused TLS handshake failures because the tunnel was trying to verify Caddy’s internal cert. Pointing it straight at n8n over plain HTTP removed the problem entirely.

Webhook registration is a one-time curl call:

curl -X POST "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \
  -d "url=https://hook.semesmieh.com/webhook/telegram-bot" \
  -d "secret_token=${TELEGRAM_WEBHOOK_SECRET}"

After that, every message Telegram delivers to the bot hits n8n directly.

The n8n Workflow

Here’s the full workflow visualized — 25 nodes orchestrating the entire request lifecycle from webhook ingestion through delivery:

n8n workflow diagram: Telegram bot complete flow with all 25 nodes and connections

The workflow has 25 nodes. Most of them are simple HTTP Request or Send Message nodes. The interesting ones are the Code nodes that handle session state and routing logic:

Four Things That Fought Back

The infrastructure setup was mechanical. The workflow debugging was not. Four specific n8n behaviours caused most of the pain.

1. The Code Node Sandbox Blocks More Than You Think

n8n runs Code nodes in a V8 isolate. Two things I reached for instinctively were blocked:

process.env throws inside Code nodes by default. The fix is either adding N8N_BLOCK_ENV_ACCESS_IN_NODE=false to the environment, or — better — using $env.VARIABLE_NAME inside IF node expressions, which runs outside the sandbox. I moved all secret comparisons to IF nodes and only kept $env in Code nodes where truly needed.

new URL(url).hostname throws silently. There’s no error output, no red node — the expression just returns empty. I spent longer than I’d like to admit on this one. The fix is a regex:

const hostMatch = url.match(/^https?:\/\/([^/?#:]+)/);
const hostname = hostMatch ? hostMatch[1] : null;

It’s less elegant but it works. The lesson: when a Code node silently produces no output, assume something is throwing — not that your logic is wrong.

2. Context Disappears After HTTP Nodes

The workflow calls answerCallbackQuery to dismiss the loading spinner on the Telegram keyboard. That’s a Telegram API HTTP call. After it completes, $json in every downstream node becomes { "ok": true } — Telegram’s response. Every field from Process Callback (chatId, url, quality, jobId) is gone.

The fix is to stop relying on $json and always reference the upstream node explicitly:

// Instead of $json.chatId (wrong after any HTTP node)
$('Process Callback').first().json.chatId

Every node that runs after Answer Callback Query uses this pattern. It’s repetitive but unambiguous — you always know exactly where the data is coming from.

3. Static Data Scope: ‘node’ vs ‘global’

I used $getWorkflowStaticData('node') to store session state in Process Message and retrieve it in Process Callback. It returned empty every time. The reason: 'node' scope is per-node, so each Code node sees only its own isolated store. Two different nodes writing and reading 'node'-scoped data are completely invisible to each other.

// Wrong — each node sees a different store
const store = $getWorkflowStaticData('node');

// Correct — shared across the entire workflow
const store = $getWorkflowStaticData('global');

This is documented, but it’s the kind of thing you only notice when cross-node state silently fails. Both Process Message and Process Callback now use 'global'.

4. The Cloudflare Tunnel 502

After the webhook was registered and verified, the first real messages resulted in 502 Bad Gateway. The tunnel configuration had the origin set to https://192.168.20.71 — the Caddy CT. The tunnel was trying to establish a TLS connection to Caddy, which presented an internal self-signed cert. The tunnel rejected it. Everything backed up silently.

The fix was to point each Cloudflare tunnel hostname directly at the target service over plain HTTP:

Hostname Old origin New origin
hook.semesmieh.com https://192.168.20.71 http://192.168.20.75:5678
n8n.semesmieh.com https://192.168.20.71 http://192.168.20.75:5678

Caddy still handles TLS for internal access. For the Cloudflare tunnel, the connection is encrypted by Cloudflare on the public side — the internal hop from the tunnel daemon to n8n can be plain HTTP without any security compromise.

Never Go Silent

The design principle I kept coming back to: every path through the workflow must send the user a message. A bot that silently fails is worse than useless because you don’t know whether to retry or wait.

Two failure paths were initially silent:

yt-dlp worker errors — when yt-dlp returns an error (403 Forbidden, private video, geo-block), the raw output looks like ERROR: [youtube] abc123: This video is unavailable. The Send Worker Error node strips the prefix and shows the user something readable:

// In the error message expression
{{ $json.error.replace(/^ERROR:\s*/, '').replace(/\n$/, '').slice(0, 300) }}

Direct send failures — if the Telegram API rejects the direct file send (size edge case, API error), there was originally no handler. Adding a Send Direct Error node on the false branch of IF Send OK closed that gap. The user gets “Could not send file directly — here’s a download link instead” and the workflow falls through to the publish path.

The rule I settled on: if a node can fail, its failure branch must produce a user-visible message. Logging to n8n’s execution history is not a substitute — the user’s chat is the output surface.

What It Feels Like to Use

You paste a URL. Two seconds later you get an inline keyboard. You tap a quality. You get “Downloading…” almost immediately, then the file arrives. For small files it’s in the chat itself. For large ones it’s a link. The whole interaction feels like a native feature of Telegram rather than a bot you’re talking to.

The challenge turned out to be less about yt-dlp — that part was half an hour of work. The real challenge was understanding n8n’s execution model well enough to trust it. Once you know where context lives, where it disappears, and which scopes are shared, the workflow snaps into place. Before you know those things, you spend a lot of time staring at empty $json objects wondering what went wrong.

The friend who issued the challenge has been using it since. Challenge: done.