Skip to main content

InnerTube Proxy

YouTube's InnerTube player and updated_metadata endpoints return LOGIN_REQUIRED from certain datacenter IPs. The InnerTube Proxy is a standalone Rust binary (Actix Web) that forwards these requests from a host with a trusted IP.

Architecture

API Server (Hetzner) ── POST ──▶ Rust Proxy (Proxmox) ── POST ──▶ YouTube InnerTube
innertube-proxy /youtubei/v1/*
◀── Response ───────────────────── ◀── Response ──────────────

Only player (total views) and updated_metadata (like count) are proxied. Chat polling (get_live_chat) and broadcast discovery (browse) call YouTube directly — they work from datacenter IPs and are latency-sensitive.

Why not a Cloudflare Worker?

The proxy was a Cloudflare Worker (apps/innertube-proxy/src/index.ts + wrangler.toml + deploy-innertube-proxy.yml). It was removed in 24dc5452 on 2026-05-22 and replaced by this Rust binary.

Reason: YouTube returns LOGIN_REQUIRED for the player endpoint from Cloudflare Edge IPs as well — not just from Hetzner Cloud and GitHub Actions/Azure. A Worker sits on exactly the kind of datacenter IP range this proxy exists to escape, so it cannot solve the problem it was built for. The trusted host (Proxmox) can reach the endpoint; Cloudflare cannot.

Do not reintroduce a Worker that calls youtubei/v1/* itself — it will return empty total_views / like_count. The only sound Cloudflare role here would be a router in front of one or more trusted-IP proxies (Cloudflare terminates TLS and forwards to a self-hosted backend, never fetching YouTube directly).

Configuration

SettingENV OverrideDefaultDescription
innertube_proxy_urlsLUMIO__YOUTUBE__INNERTUBE_PROXY_URLS[]Proxy host list. The API fails over between entries by itself. ENV form is comma-separated (https://a,https://b).
innertube_proxy_urlLUMIO__YOUTUBE__INNERTUBE_PROXY_URLunsetBack-compat single-host alias for innertube_proxy_urls. Folded into the host list (deduplicated) when set.
innertube_proxy_tokenLUMIO__YOUTUBE__INNERTUBE_PROXY_TOKENunsetShared bearer token for proxy auth — one token for every host.

When at least one host and the token are set, the YouTube worker routes player and updated_metadata requests through the proxy pool. When no host is configured (local dev), requests go directly to YouTube. The two URL settings are additive: innertube_proxy_urls plus the single innertube_proxy_url alias are merged into one deduplicated host list, so existing single-URL deployments keep working unchanged.

Proxy Routes

RouteYouTube Target
POST /playeryoutubei/v1/player
POST /updated-metadatayoutubei/v1/updated_metadata
GET /healthReturns 200 (Docker healthcheck)

The api_key query parameter is forwarded from the incoming request.

Route names are not YouTube path names

The proxy route is /updated-metadata (hyphen); YouTube's own endpoint is youtubei/v1/updated_metadata (underscore). A client must resolve the path per transport — sending the hyphenated route straight to youtube.com returns 404 and silently leaves the like count empty. InnerTubeEndpoint in crates/lo-youtube-api/src/innertube/browse.rs holds both names, and tests on either side pin the mapping.

Auth

The proxy validates Authorization: Bearer \{token\} against the INNERTUBE_PROXY_TOKEN env var. Invalid or missing tokens return 401.

Operational metrics (Prometheus)

The proxy exposes the shared lo-metrics /metrics endpoint (ZAF-674). The bearer check on POST /player lives in the handler, not as middleware, so /metrics is not served on the public LISTEN_ADDR server (which the deployment publishes on 3100) — it would be unauthenticated there. Instead it runs on a separate internal server:

EnvDefaultDescription
METRICS_ADDR127.0.0.1:9100host:port for the internal GET /metrics server. Loopback by default.

9100 is the single dedicated metrics port shared by every service that runs a separate metrics server (apps/api, both workers); it is collision-free because each runs in its own container (ZAF-683). To scrape it, set METRICS_ADDR to an internal address (e.g. 0.0.0.0:9100) and publish only that port to the Prometheus network — never fold it onto the public 3100. The security is carried by network isolation (an unpublished port), not the bind address. A bind failure is fail-open: the proxy logs and keeps serving its payload routes. See Operational Metrics.

Deployment

The proxy runs as an OCI container built with Bazel:

# Build image
bazel build //apps/innertube-proxy:innertube-proxy_image

# Push to registry
bazel run //apps/innertube-proxy:innertube-proxy_push

Docker Compose:

innertube-proxy:
image: ghcr.io/zaflun/lumio/innertube-proxy:latest
environment:
INNERTUBE_PROXY_TOKEN: "${INNERTUBE_PROXY_TOKEN}"
LISTEN_ADDR: "0.0.0.0:3100"
# Internal /metrics server. Bind 0.0.0.0 only if a scraper needs it, and then
# expose 9100 solely on the trusted scraper network — never publish it publicly.
METRICS_ADDR: "127.0.0.1:9100"
RUST_LOG: "info"
ports:
# Public payload port only. `/metrics` (9100) is deliberately NOT published here.
- "3100:3100"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3100/health"]
interval: 30s
timeout: 5s
retries: 3

The API connects via Docker network: LUMIO__YOUTUBE__INNERTUBE_PROXY_URLS=http://innertube-proxy:3100 (the singular …_PROXY_URL alias still works for existing single-host deployments).

Image tags

release-images.yml publishes ghcr.io/zaflun/lumio/innertube-proxy on GitHub Release published (or via workflow_dispatch). Tags follow the release channel: a pre-release publishes beta (plus the version and SHA tags), a full release publishes latest and the MAJOR.MINOR tag. latest therefore only exists once a non-pre-release has been cut — pin an explicit version or SHA tag if you need a specific build.

Running on multiple hosts

The API talks to the proxy pool directly and fails over between hosts by itself — no load balancer, router, or DNS trick in front. Configure every host in innertube_proxy_urls (apps/api/src/config.rs) and the API rotates across them:

[youtube]
innertube_proxy_urls = [
"http://innertube-proxy-a:3100",
"http://innertube-proxy-b:3100",
]
innertube_proxy_token = "…" # one shared token for every host

or, ENV-only (comma-separated):

LUMIO__YOUTUBE__INNERTUBE_PROXY_URLS=http://innertube-proxy-a:3100,http://innertube-proxy-b:3100
LUMIO__YOUTUBE__INNERTUBE_PROXY_TOKEN=…

Failover is passive and sticky (innertube_fetch_json in crates/lo-youtube-api/src/innertube/browse.rs): each request starts at the last host that answered and rotates forward only when a host fails — on a transport error, a non-2xx status, or a LOGIN_REQUIRED-shaped body (the datacenter/edge IP-block signal the proxy exists to dodge). The host that answers becomes the new sticky start, so a dead host is skipped on subsequent calls without a background health-check loop or any extra config. With a single host configured the behaviour is identical to a one-URL deployment.

Every host must share the same INNERTUBE_PROXY_TOKEN, since the API sends one shared token to all of them. The single-host innertube_proxy_url alias still works and is merged (deduplicated) into the host list, so existing deployments need no change.

A fronting layer (Cloudflare router, Caddy/nginx/HAProxy, DNS round-robin) is not used here — the failover is owned by the API. A Cloudflare Worker in particular sits on a datacenter IP and cannot reach youtubei/v1/* at all (see Why not a Cloudflare Worker?).

Token Management

Generate a token:

openssl rand -base64 32

Set it as env var on the proxy container (INNERTUBE_PROXY_TOKEN) and the API server (LUMIO__YOUTUBE__INNERTUBE_PROXY_TOKEN). Both must match.

Health Check

.github/workflows/innertube-health.yml runs cargo test -p lo-youtube-api --test innertube_smoke -- --ignored --nocapture with the INNERTUBE_PROXY_URL and INNERTUBE_PROXY_TOKEN GitHub secrets (the smoke test reads the single-host form) to verify total_views end-to-end. If the proxy is down, the health check emits a warning or failure alert to Discord.

Because player and updated_metadata return LOGIN_REQUIRED from datacenter IPs (see above), a health-check run that does not reach the proxy — secrets unset, or the proxy unreachable — falls back to a direct call from the GitHub Actions runner and reports both like_count and total_views as empty, while the direct browse / chat checks stay green. To make that failure legible, the smoke test installs a tracing subscriber that routes the crate's WARN diagnostics to the CI log (the workflow runs with --nocapture). The log then names the exact failure path — a playabilityStatus bot-challenge (LOGIN_REQUIRED), a non-200 proxy status, or a missing viewCount / videoDetails field — instead of a bare "unavailable". Raise the level with RUST_LOG when running the test locally.

Key Files

FilePurpose
apps/innertube-proxy/src/main.rsActix Web proxy server
apps/innertube-proxy/BUILD.bazelBazel build + OCI image targets
crates/lo-youtube-api/src/innertube/browse.rsInnerTubeProxy host pool, proxy routing + multi-host failover
apps/api/src/config.rsYouTubeSettings proxy fields (innertube_proxy_urls + single-host alias)
apps/api/src/workers/youtube.rsProxy config wiring