╔═════════════════════════════════════════════════════════════════╗
║ ║
║ ▄▄▄ ▄▄ ▄▄▄ █ █ ▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄ ▄▄ ▄ ▄ ║
║ █ █▄▄█ █ █▄▄█ █▄▄ ▀▀█▄ █ █▄█ █▄▄ █▄▄█ █▄█▄█ ║
║ █▄▄▄ █ █ █▄▄▄ █ █ █▄▄▄ ▄▄▄█ █ █ █▄ █▄▄▄ █ █ █ █ █ ║
║ ║
╚═════════════════════════════════════════════════════════════════╝
Fully headless Twitch streaming, with a real control panel.
Render any webpage with headless Chromium, encode with FFmpeg, push to Twitch RTMP — all without a desktop, X11, OBS, or a GPU. A Next.js panel handles login, chat, alerts, music, VODs, custom scenes, overlays, scheduling, and a couple of chat-driven games.
┌────────────────────────────────────────────────────────────────────┐
│ │
│ Webpage Headless FFmpeg Twitch │
│ (any URL) ────► Chromium ────────► libx264 / HW ───► RTMP │
│ (CDP yuv420p │
│ screencast) + AAC │
│ │
└────────────────────────────────────────────────────────────────────┘
▲
│ start / stop / scene / overlay / music / VOD
│
┌─────────┴─────────┐
│ Next.js panel │
│ /admin │ Twitch OAuth2 login,
│ │ chat, alerts, scenes,
│ (Twitch login) │ music, VODs, games,
└───────────────────┘ branding, scheduler
- No virtual display. No Xvfb, no X server, no DISPLAY env var.
- No OBS. No browser sources. No scene compositing in userspace.
- No GPU required. Software encoding works fine. Hardware encoders auto-detected and used when available (Pi VC4, NVENC, QSV, VideoToolbox).
- No desktop environment. Runs on a bare Ubuntu Server, a Raspberry Pi, or any Docker host with nothing else installed.
| 🎬 Scenes are webpages | Anything Chromium can render — Grafana dashboards, your own React app, a CodeMirror editor, a Notion page. Bundled scenes for Starting Soon / BRB / Ending / Offline / Music / Pet / Datacenter / RTMP Ingest. |
| 🛂 OAuth2 login + invites | Twitch authorization-code flow, first-login-wins ownership, HMAC-signed cookies, auto-refresh. Owner can invite moderators to drive the panel (v1.13). |
| 💬 Real chat | Twitch chat over Helix + EventSub (v1.8) — read, write, custom command engine with cooldowns and live-stat variables ({uptime}, {viewers}, {game}, …), AutoMod rules (delete / timeout / ban). |
| 🔔 EventSub alerts | Follows / subs / gifts / resubs / cheers / raids via WebSocket EventSub, with a drop-in popup widget. |
| 🖼️ Scene overlays | Chat box, alerts ticker, Now Playing card, uptime/viewer badge — each toggleable per scene from the Branding tab (v1.10). |
| 📡 RTMP ingest | Push from OBS (or any RTMP encoder) to CacheStream, render it as a scene, composite overlays on top, forward to Twitch. Multi-key support (v1.11). |
| 🎞️ Browser-source embeds + VOD reruns | Drop Streamlabs/NightBot widgets into a sandboxed scene; rerun past Twitch broadcasts as scenes (v1.11). |
| 🎨 Studio mode | Drag/resize overlays on a 1080p preview canvas, side-by-side with the live program feed. Take-to-program in one click. |
| 🎵 Music + radio | Upload MP3/FLAC/OGG, parse tags + cover art, queue / loop / shuffle / volume. Or stream any Icecast/Shoutcast URL. Audio mixed via shared FIFO so changes never drop the video. |
| 📼 VOD broadcast | Stream pre-recorded files or remote URLs through a separate FFmpeg pipeline. No Chromium round-trip → much lower CPU. |
| 🧬 Auto-CPU tuning | Detects Pi vs ARM vs x86, picks resolution / bitrate / encoder. Hot-throttles to 720p if the CPU temp hits 80 °C, restores on cool-down. |
| 🎮 Chat games | Tamagotchi-style AI Pet + "Twitch Plays Datacenter" sim, both driven by chat commands. |
| 🛞 Scheduler | Minute-resolution rotation across scenes + overlay sets, per-day-of-week. |
| 🪪 Branding | Upload a logo, set a display name + tagline + accent colour. Threads through every scene + the landing page + the admin header. |
| 📱 Mobile control panel | The admin UI tightens to a drawer + stacked layout under 700 px so you can switch scenes from your phone (v1.13). |
| 🛡️ Long-stream stability | Frame-flow watchdog, periodic Chromium recycle, memory-pressure auto-recycle, bounded teardown. Multi-day streams stay healthy (v1.13.3–v1.13.5). |
| 🔄 One-click updates | Panel "Update" button + host-side watcher pulls + rebuilds the latest release (v1.9.3). |
| 📚 Examples | examples/ ships drop-in scene + game templates so you can fork your own without learning the codebase first. |
One command + a wizard. Since
v1.7, almost everything is configured from a first-run setup wizard in the panel itself. The only.envvalue you need before bringing it up isPUBLIC_URL.
git clone https://github.com/cachenetworks/CacheStream.git
cd CacheStream
cp .env.example .env
$EDITOR .env # set PUBLIC_URL (see below)
docker compose up -dThen open https://your-tunnel-hostname/ → the setup wizard walks
you through Twitch dev-app credentials and login. First login
claims permanent ownership. Click Start stream when ready.
| Var | What | Where to get it |
|---|---|---|
PUBLIC_URL |
Your Cloudflare-Tunnel hostname (or http://localhost:7788 for local dev) |
https://one.dash.cloudflare.com/ → Zero Trust → Networks → Tunnels |
When you create the Twitch dev app (the wizard prompts you to),
set the OAuth Redirect URL to <PUBLIC_URL>/api/auth/twitch/callback
— exact match, no trailing slash.
No stream key, no client secret, no session secret in .env.
The wizard collects what it needs and stores it in SQLite; the
broadcaster's stream key is pulled directly from Twitch via the
channel:read:stream_key OAuth scope on every login.
INTERNAL_API_TOKEN is auto-generated by the web container at
first boot and shared with the streamer over a docker volume.
SESSION_SECRET is auto-generated on first request.
Everything else has sensible defaults, including the encoder profile which auto-tunes to your host.
Owner can mint single-use invite links from the Staff tab in
the panel. The invitee opens the URL, logs in with their own
Twitch account, and gets a MOD badge in the panel header. Mods
can drive everything except branding, stream-key rotation, host
updates, and staff management.
docker compose --profile ghcr up -dPulls ghcr.io/cachenetworks/cachestream-web +
ghcr.io/cachenetworks/cachestream-streamer, built multi-arch
(amd64 + arm64) by GitHub Actions on every tag. Skips the
~5 minute first-time build.
┌────────────────────────────────────────────────────────────────┐
│ Browser (operator) │
│ https://your-host/admin │
│ Twitch OAuth login, first-login-wins ownership │
└────────────────────────────────────────────────────────────────┘
│ HTTPS
▼
┌────────────────────────────────────────────────────────────────┐
│ Cloudflare Tunnel │
│ (configured in the Cloudflare dashboard) │
│ Terminates TLS, forwards to host:7788 │
└────────────────────────────────────────────────────────────────┘
│ plain HTTP, inside the host
▼
┌────────────────────────────────────────────────────────────────┐
│ cachestream-web · Next.js 14 (TypeScript, App Router) │
│ │
│ / public landing │
│ /admin control panel (auth-gated) │
│ /scene/* ten built-in scenes + custom scenes │
│ /widgets/* chat + alert iframe widgets │
│ /api/* 70+ routes (OAuth, streamer RPC, scenes, │
│ overlays, schedule, twitch helix, chat IRC, │
│ eventsub, music, VODs, games, branding) │
│ │
│ Long-lived workers: chat IRC, EventSub WS, scheduler, │
│ Pet game tick, Datacenter game tick, music FFmpeg │
│ │
│ SQLite (better-sqlite3) at /app/data/cachestream.db │
└────────────────────────────────────────────────────────────────┘
│ HTTP + bearer token
│ (internal compose network)
▼
┌────────────────────────────────────────────────────────────────┐
│ cachestream-streamer · Node + Chromium + FFmpeg │
│ │
│ Headless Chromium (puppeteer-core, --headless=new) │
│ CDP Page.startScreencast → JPEG frames │
│ │ │
│ ▼ binary write into ffmpeg stdin │
│ FFmpeg (image2pipe → libx264/HW → AAC → FLV → RTMP) │
│ + audio FIFO shared with the web container │
│ + auto-reconnect with exponential backoff │
│ + thermal monitor (Pi/ARM hosts) │
│ + VOD pipeline (file/URL → RTMP, bypasses Chromium) │
│ │
│ Auto-profile picks encoder + resolution + bitrate per host│
└────────────────────────────────────────────────────────────────┘
│ RTMP
▼
rtmp://live.twitch.tv/app/<key>
Two containers, one .env, one docker compose up -d. The web container
is the only one published to the host; the streamer is internal-only.
CacheStream/
├── apps/
│ ├── streamer/ Node + Chromium + FFmpeg worker
│ │ ├── Dockerfile
│ │ ├── entrypoint.sh Drops root → streamer after fixing volume perms
│ │ ├── package.json
│ │ └── src/
│ │ ├── index.js
│ │ ├── config.js env vars + auto-profile merge
│ │ ├── autoprofile.js Pi vs x86 host detection
│ │ ├── thermal.js CPU temp monitor + auto-throttle
│ │ ├── logger.js
│ │ ├── stream.js Puppeteer → FFmpeg → RTMP pipeline
│ │ └── api.js Internal HTTP control API
│ └── web/ Next.js 14 + TypeScript
│ ├── Dockerfile Multi-stage standalone build
│ ├── entrypoint.sh Drops root → nextjs after fixing volume perms
│ └── src/ see "Built with" below for the full tree
├── examples/ 👈 Copy-paste templates for new scenes + games
│ ├── README.md
│ ├── scenes/
│ │ ├── countdown-poster/ Simple full-screen poster + countdown
│ │ ├── now-playing-card/ Music now-playing card you can drop anywhere
│ │ └── chat-leaderboard/ Live top chatters scoreboard
│ └── games/
│ ├── 8ball/ !8ball — minimal chat-command game
│ └── reaction/ !go — first to type wins
├── .github/workflows/ CI: build + push images to GHCR on tag
├── docker-compose.yml
├── .env.example
├── CHANGELOG.md
├── CONTRIBUTING.md
├── SECURITY.md
├── LICENSE
└── README.md
Go to https://dev.twitch.tv/console/apps → Register Your Application.
- Name: anything (
CacheStreamis fine). - OAuth Redirect URL:
<PUBLIC_URL>/api/auth/twitch/callback— must matchPUBLIC_URLfrom your.envexactly, includinghttps://and hostname, with no trailing slash. - Category: Application Integration.
- Client Type: Confidential.
Copy the Client ID into TWITCH_CLIENT_ID. Click New Secret and
copy that into TWITCH_CLIENT_SECRET. (You can rotate the secret later
without breaking anything.)
In the Cloudflare dashboard:
- Zero Trust → Networks → Tunnels → Create a tunnel → name it.
- Install the connector on your host (the dashboard gives you a one-liner).
- Under Public Hostnames add:
- Subdomain / Domain: e.g.
stream.example.com - Service:
HTTP - URL:
localhost:7788
- Subdomain / Domain: e.g.
- That hostname becomes your
PUBLIC_URL=https://stream.example.com.
You don't need to expose port 7788 publicly — Cloudflare connects
outbound from the tunnel. For defence in depth, bind it to localhost
only in docker-compose.yml:
ports:
- "127.0.0.1:7788:7788"cp .env.example .env
$EDITOR .envThe six required vars are listed in the Quickstart. Everything else is
optional — most knobs default to auto and tune themselves.
docker compose up -d
docker compose logs -fThe streamer logs its auto-profile pick on boot:
{"msg":"auto-profile picked","arch":"arm64","cores":4,"ramGB":4,
"piModel":"Raspberry Pi 4 Model B Rev 1.4","profile":"pi",
"encoder":"Raspberry Pi V4L2 M2M (HW)","resolution":"1280x720@30",
"videoBitrateKbps":3500}
The web container migrates its SQLite schema on first boot:
[boot] no broadcaster tokens — skipping chat/eventsub startup
[boot] seeded 8 built-in scene presets
Open <PUBLIC_URL>/admin → Login with Twitch.
In the panel:
- Go to Branding, upload a logo, set your display name + accent.
- Go to Status, pick a scene (Starting Soon is good for first boot).
- Click Start stream.
Within ~10 seconds Twitch's dashboard at https://dashboard.twitch.tv/stream-manager shows you live.
To swap scenes mid-stream, pick a different preset or use Studio to compose overlays on a draft and Take → Program in one click.
The panel has thirteen tabs, each focused on one workflow. Two (Branding, Staff) are owner-only; the other eleven are visible to both the owner and any invited moderator.
Live stream state, scene URL, uptime, frame count, resolution, bitrate, ingest endpoint, stream-key presence, codec, auto-profile category, CPU temp, last error. Start / Stop / Restart. Scene URL swap. Saved scene presets (one-click activate, delete). Overlay set library. Minute- resolution rotation scheduler.
Large preview canvas at 1920×1080 with letterboxed auto-fit zoom. Drag overlays around the canvas, grab the cyan resize handle. Sidebar with add-layer buttons, layer list, per-layer inspector (text/HTML/source + X/Y/W/H numeric inputs). Small floating Program (live) thumbnail in the bottom-right. Optional grid + rule-of-thirds guides. Take → Program atomically switches the live scene and applies the draft overlay set.
Lists all eleven scenes:
- Eight built-ins (Hello World, Starting Soon, BRB, Ending, Offline, Music, Pet, Datacenter)
- Five template-based custom scene types (Starting Soon, BRB, Ending, Generic, Raw HTML/CSS)
Save built-ins as preset, copy URL, open preview. Author your own with
per-template fields (title / subtitle / accent / countdown ISO / social
handles / raw HTML) and serve at /scene/custom/<slug>.
Edit Twitch broadcaster metadata via Helix: title, category (debounced search), tags, language. Shows live viewer count when broadcasting.
Reads via EventSub channel.chat.message over WebSocket (Helix
chat — replaced the old IRC client in v1.8). Sends via Helix
POST /chat/messages. Live feed via SSE; composer to send as
the broadcaster. Connection-state badges for chat + EventSub.
If your token is missing scopes, one-click re-authorize link.
- Custom commands — trigger on first word of a message
(with or without leading
!). Per-trigger cooldowns, mod-only / sub-only flags. Response templating:{user},{channel},{arg1}…{args}plus live-stat variables{uptime},{viewers},{game},{title},{followers}(v1.12) — resolved against a 5s Helix snapshot cache. - AutoMod rules —
contains/startswith/regexmatching; actionsdelete/timeout <s>/ban. Uses the Twitch moderation API.
Live feed of EventSub notifications. Auto-subscribed on connect:
channel.follow, channel.subscribe, channel.subscription.gift,
channel.subscription.message (resubs), channel.cheer, channel.raid.
- Upload MP3/FLAC/OGG/WAV/OPUS up to 200 MB each.
- Tag parsing with
music-metadata— title, artist, album, duration, embedded cover art extracted to<DATA_DIR>/covers/. - Inline edit for title/artist/album (marked
edited, never overwritten by rescans). - Now-playing card with cover thumbnail.
- Radio presets — any Icecast/Shoutcast/direct-stream URL.
- Volume / loop / shuffle.
- Upload MP4/MOV/MKV/WEBM/M4V up to 4 GB.
- Remote URLs for anything FFmpeg can ingest.
- Per-VOD edit + loop + delete + scan-dir (pick up files you SFTP'd in directly).
- Playing a VOD bypasses Chromium entirely — much lower CPU than
rendering a
<video>element.
- AI Pet (
/scene/pet) — 6 stats, 4 evolution tiers (Datablob → Loglurker → Cachewyrm → Stackwraith). Chat-driven via!feed!pet!play!sleep!teach <word>(mod)!insult!hit. - Twitch Plays Datacenter (
/scene/datacenter) — manage servers, power, coolant, uptime under cyberattacks. Chat commands:!add-server!defend!cool!power+!invest!restart.
External content you can use as scenes:
- Browser-source embeds — Streamlabs alert boxes, NightBot timers, donation tickers, any URL widget. Rendered in a sandboxed iframe so the embed can't read panel cookies.
- Twitch VOD archive — pulls your last 20 broadcasts via
Helix
GET /videos; one click promotes a VOD to a "rerun" scene that plays via the official Twitch embed player. - Multi-key RTMP ingest — additional keys alongside the default. Push from OBS / phone / capture card simultaneously, switch between them as scenes.
Display name, tagline, accent colour with 8 quick-pick swatches
(v1.10), logo upload (PNG/JPG/WEBP/SVG/GIF, ≤4 MB), and the
Scene overlays toggle card (chat box, alerts ticker, Now
Playing card, uptime badge — per-overlay enable + corner). All
flow into every scene's ribbon, the public landing page, and
the admin header. Inline live preview before saving.
Mint single-use moderator invite codes with optional expiry (1 h / 24 h / 7 d / 30 d / never). Copy the URL, share with the mod, revoke any time. Active moderator list with one-click revoke.
| URL | Description | Query params |
|---|---|---|
/scene |
Cyberpunk "Hello World" intro | — |
/scene/starting-soon |
Animated standby + optional countdown | ?title= ?subtitle= ?accent= ?bg= ?at=ISO ?label= |
/scene/brb |
Be right back / intermission card | ?title= ?subtitle= ?accent= ?bg= |
/scene/ending |
Off-air thank you + social handles | ?title= ?subtitle= ?twitch= ?youtube= ?twitter= ?discord= ?accent= ?bg= |
/scene/offline |
Neutral idle fallback | ?title= ?subtitle= ?accent= |
/scene/music |
Radio scene with cover art + visualiser | — (driven by music engine) |
/scene/pet |
AI Pet creature | — (driven by chat) |
/scene/datacenter |
NOC dashboard sim | — (driven by chat) |
/scene/custom/<slug> |
Your custom scene | (set per template in the editor) |
Every scene uses the same SceneFrame shell, so the branding ribbon
(logo + display name) and accent colour show up consistently across
all of them.
See the examples/ directory.
Three scene templates you can copy into a custom scene (Scenes tab → New custom scene → Raw HTML/CSS) and tweak:
| Folder | What it shows | Difficulty |
|---|---|---|
examples/scenes/countdown-poster/ |
Full-screen poster with title + image + countdown | ⭐ |
examples/scenes/now-playing-card/ |
Music now-playing card you can drop on any scene | ⭐⭐ |
examples/scenes/chat-leaderboard/ |
Live top chatters scoreboard via SSE | ⭐⭐⭐ |
Two game templates you can fork into new apps/web/src/lib/games/*.ts
modules:
| Folder | What it shows | Difficulty |
|---|---|---|
examples/games/8ball/ |
Minimal command-response game with the chat bus | ⭐ |
examples/games/reaction/ |
First-to-type-wins reaction game with a tick loop, SSE state stream, and scoreboard scene | ⭐⭐⭐ |
Each example is heavily commented and self-contained. Walk through
examples/README.md for the full guide.
All /api/* routes other than /api/health, /api/auth/twitch/*,
and the SSE / now-playing / scene-data endpoints require an owner
session cookie.
| Method | Path | Effect |
|---|---|---|
GET |
/api/health |
Liveness |
GET |
/api/auth/twitch/login |
302 to Twitch |
GET |
/api/auth/twitch/callback |
Issue session + persist tokens |
POST |
/api/auth/logout |
Destroy session |
| Method | Path | Body |
|---|---|---|
GET |
/api/stream/status |
— |
POST |
/api/stream/{start,stop,restart} |
— |
POST |
/api/stream/scene |
{ url } |
| Method | Path | Body |
|---|---|---|
GET/POST/DELETE |
/api/scenes[/:id[/activate]] |
name+url / — |
GET/POST |
/api/scenes/custom |
{ name, template, slug?, config } |
GET/PATCH/DELETE |
/api/scenes/custom/:id |
partial of above |
| Method | Path | Body |
|---|---|---|
GET/POST/DELETE |
/api/overlays[/:id[/activate]] |
name + overlay array |
PATCH |
/api/overlays/:id/update |
{ overlays: [...] } |
POST |
/api/overlays/clear |
— |
GET/POST/PATCH/DELETE |
/api/schedule[/:id] |
scene+overlay+time+days |
| Method | Path | Body |
|---|---|---|
GET/PATCH |
/api/twitch/channel |
{ title?, game_id?, tags?, broadcaster_language? } |
GET |
/api/twitch/categories/search?q= |
— |
| Method | Path | Body |
|---|---|---|
GET/POST |
/api/chat/status |
{ action: "start"|"stop" } |
POST |
/api/chat/send |
{ text } |
GET |
/api/chat/log?limit=N |
— |
GET |
/api/chat/stream |
SSE chat feed (public — overlays use it) |
GET |
/api/alerts/stream |
SSE EventSub feed |
| Method | Path | Body |
|---|---|---|
GET/POST/PATCH/DELETE |
/api/commands[/:id] |
trigger / response / cooldown / flags |
GET/POST/PATCH/DELETE |
/api/automod[/:id] |
pattern / matchType / action / duration |
| Method | Path | Body |
|---|---|---|
GET |
/api/music/status |
— |
GET/POST |
/api/music/library |
rescan |
PATCH/DELETE |
/api/music/library/:id |
inline edit + delete file |
POST |
/api/music/upload |
multipart |
GET |
/api/music/file/:id |
raw audio (public — visualiser uses it) |
GET |
/api/music/cover/:id |
extracted cover art (public) |
POST |
/api/music/play |
{ trackId } |
POST |
/api/music/{next,stop} |
— |
POST |
/api/music/volume |
{ value: 0..1 } |
POST |
/api/music/mode |
{ loop?, shuffle? } |
GET/POST |
/api/music/radio |
{ url, name? } / { presetId } |
DELETE |
/api/music/radio/:id |
— |
GET |
/api/music/now |
public now-playing snapshot |
| Method | Path | Body |
|---|---|---|
GET/POST |
/api/vods |
{ name, kind, pathOrUrl, loop? } |
PATCH/DELETE |
/api/vods/:id |
partial / — |
POST |
/api/vods/upload |
multipart |
POST |
/api/vods/scan |
re-scan ./media/vods |
POST |
/api/vods/:id/play |
— |
POST |
/api/vods/stop |
return to scene |
| Method | Path | Body |
|---|---|---|
GET |
/api/games/pet/state |
public snapshot |
GET |
/api/games/pet/stream |
SSE |
POST |
/api/games/pet/rename |
{ name } |
POST |
/api/games/pet/reset |
— |
GET |
/api/games/datacenter/state |
public snapshot |
GET |
/api/games/datacenter/stream |
SSE |
POST |
/api/games/datacenter/reset |
— |
| Method | Path | Body |
|---|---|---|
GET |
/api/branding |
public — scenes need it |
PATCH |
/api/branding |
{ displayName?, tagline?, accent? } |
GET |
/api/branding/logo |
public binary stream |
POST |
/api/branding/logo |
multipart |
DELETE |
/api/branding/logo |
— |
The streamer's internal API on port 7789 (/start, /stop, /scene,
/overlays, /vod/play, /vod/stop, /status, /health) is not
exposed to the host. Only the web container talks to it, with a bearer
token from INTERNAL_API_TOKEN.
STREAM_PROFILE=auto (the default) detects the host on boot:
| Host signature | Picked profile | Codec choice |
|---|---|---|
| Raspberry Pi (any) | 720p30, ~2.8–3.5 Mbps | h264_v4l2m2m (HW) → libx264 ultrafast |
| Weak ARM, ≤4 cores or ≤4 GB RAM | 720p30, 3.0 Mbps | libx264 ultrafast |
| 1–2 core x86 | 720p30, 3.5 Mbps | libx264 veryfast |
| 4–8 core x86 | 1080p30, 4.5 Mbps | libx264 veryfast (zerolatency) |
| 12+ core x86 | 1080p60, 6.0 Mbps | libx264 medium (no zerolatency) |
In auto mode the profile wins over individual STREAM_* env vars so
stale values in an old .env don't sabotage tuning. To override a
single knob:
STREAM_PROFILE=manual
STREAM_WIDTH=1280
STREAM_HEIGHT=720
# … fill in everything else explicitlyAuto-detected via ffmpeg -encoders at boot. Preference order:
h264_v4l2m2m— Raspberry Pi VC4 HW encoderh264_nvenc— NVIDIAh264_qsv— Intel Quick Synch264_videotoolbox— macOS hosts via Docker Desktoplibx264— software fallback
Force a specific codec with STREAM_VIDEO_CODEC=libx264.
On hosts that expose /sys/class/thermal/thermal_zone0/temp (Pi,
most Linux servers):
- Crosses
STREAM_THERMAL_THROTTLE_C(default 80 °C) → hot-restart with a thermal-safe profile (720p / lower bitrate /ultrafast). - Falls below
STREAM_THERMAL_RECOVER_C(default 68 °C) → restore.
Twitch sees a quick RTMP reconnect; the broadcast doesn't fully drop. The Status tab shows the live temperature and a red badge when throttled.
For finer-grained control beyond what the auto-profile picks.
| Knob | Effect |
|---|---|
STREAM_PRESET=ultrafast |
Biggest single drop. Visible quality loss on detailed scenes, fine for dashboards. |
STREAM_X264_THREADS=2 |
Pin x264 to 2 cores so chat / music / EventSub don't get starved. |
STREAM_X264_TUNE= (empty) |
Re-enables x264 lookahead. ~150 ms more latency, but better quality at the same bitrate. |
STREAM_VIDEO_CODEC=h264_v4l2m2m |
Force Pi hardware encoder. |
| Combination | CPU impact |
|---|---|
| 1080p30 | baseline |
| 720p30 | ~55 % less encoder work |
| 1080p15 | ~50 % less encoder work |
STREAM_CAPTURE_EVERY_NTH=2 |
Halves Chromium → FFmpeg JPEG decode load on 60fps scenes |
STREAM_SCREENCAST_QUALITY=60— drops JPEG quality on the Chromium → FFmpeg pipe. Visually identical after H.264.
- Avoid
filter: blur(40px), full-screenbackdrop-filter, orbox-shadow: 0 0 200px— each forces an extra fullscreen composited layer per frame. - Animate with
transformandopacity, nottop/left/width.
STREAM_PROFILE=manual
STREAM_WIDTH=1280
STREAM_HEIGHT=720
STREAM_FPS=24
STREAM_VIDEO_BITRATE=2500
STREAM_VIDEO_MAXRATE=2500
STREAM_VIDEO_BUFSIZE=5000
STREAM_PRESET=ultrafast
STREAM_X264_THREADS=2
STREAM_SCREENCAST_QUALITY=55The OAuth Redirect URL registered at https://dev.twitch.tv/console/apps
must match <PUBLIC_URL>/api/auth/twitch/callback exactly — no
trailing slash, exact hostname, exact http vs https.
First-login-wins fired before you. Options:
- Reclaim: stop the stack, delete the data volume
(
docker volume rm cachestream_cachestream-data), log in immediately. - Pre-seed: set
INITIAL_OWNER_LOGIN=yourtwitchlogin(lowercase) in.envbefore next boot.
Fixed in v1.6 via the new entrypoint scripts. If you still see it,
rebuild the images: docker compose build --no-cache && docker compose up -d.
Twitch dropped you for missing audio. Check
docker compose logs streamer | grep ffmpeg — if audio is missing,
the silence filler in the web container failed to start. Check
docker compose logs web | grep "silence filler".
Almost always a wrong TWITCH_STREAM_KEY or a host blocking outbound
TCP/1935. Test from the host:
nc -zv live.twitch.tv 1935Stale state cookie or SESSION_SECRET changed mid-flow. Restart the
login. If persistent, the browser may be blocking third-party cookies
on the Cloudflare hostname.
- Confirm the auto-profile picked
piand (if available)h264_v4l2m2min the Status tab. - Bump the case airflow (a $5 fan helps).
- If still hot, ship the "Low-CPU preset" above.
- The thermal monitor will hot-restart to 720p if it crosses 80 °C; you
can lower the throttle threshold with
STREAM_THERMAL_THROTTLE_C=70.
Uploads >100 MB may fail on the Cloudflare free plan. For big VODs,
SFTP them straight into ./media/vods/ on the host and click
Scan dir in the VODs tab.
- The control panel sits behind Cloudflare Tunnel by default, so port
7788doesn't need to be open to the public internet. Bind to127.0.0.1:7788:7788indocker-compose.ymlfor an extra layer. - Stream key + OAuth secret + session secret + internal API token
all live in
.envand inside the containers. They never reach the browser..envis in.gitignore. - Sessions are HMAC-signed cookies (
SESSION_SECRET). Rotating that value logs every session out — useful if you suspect a leak. - The streamer's HTTP API requires a bearer token on every endpoint
except
/health. Even if someone reachedstreamer:7789via a container escape, they couldn't operate the stream without that token. - First-login-wins is convenient but means a public tunnel can be
claimed by anyone who finds the URL between deploy and login.
Pre-seed
INITIAL_OWNER_LOGINto close that window. - The raw-HTML custom scene is owner-only. The HTML inside is
rendered verbatim in the headless browser. Be careful about pasting
third-party HTML you don't trust — it can
fetch()arbitrary URLs from inside your network.
Found a security issue? See SECURITY.md.
CacheStream is designed to stay healthy through multi-day broadcasts. Several safety nets ensure a single hiccup doesn't take down the stream and quietly fail to recover:
| Knob | Default | What it does |
|---|---|---|
STREAM_WATCHDOG_TIMEOUT_SECONDS |
30 |
If state=running but no MJPEG frames have flowed to FFmpeg in this many seconds, force a reconnect. Catches TCP-idle drops on the RTMP push, dead CDP screencast sessions, and similar silent-death modes. 0 disables. |
STREAM_BROWSER_RECYCLE_HOURS |
6 |
Proactively tear down + restart the Chromium + FFmpeg pipeline after this much healthy uptime. Defends against gradual Chromium memory bloat. 0 disables. |
STREAM_MEMORY_RECYCLE_LIMIT_MB |
1500 |
If the streamer process RSS exceeds this many megabytes, force an immediate pipeline recycle. Catches fast leaks before the host OOM-kills the container. 0 disables. |
STREAM_TEARDOWN_TIMEOUT_SECONDS |
10 |
Hard deadline for _teardown(). If browser.close() or anything else hangs past this, leftover processes get SIGKILL'd and the next reconnect cycle proceeds anyway. |
The streamer's /status endpoint (polled by the panel every
5 s) now reports framesDropped, memory.rssMB,
memory.heapUsedMB, and stdinBufferedKB so you can see
backpressure or leak buildup from the Status tab before it
becomes a problem (v1.13.5).
The web container exposes the same telemetry at
GET /api/system/diag (owner / mod only) alongside live bus
listener counts per topic.
- Next.js 14 (App Router, TypeScript, standalone output)
- puppeteer-core + Chromium (headless rendering)
- FFmpeg (H.264 encoding, RTMP push, music mixing)
- better-sqlite3 (persistence)
- music-metadata (ID3/Vorbis/FLAC tags)
- ws (Twitch EventSub WebSocket)
- hls.js (RTMP-ingest playback in headless Chromium)
- nginx-rtmp (RTMP ingest + HLS transmux sidecar)
- pino (structured logging)
No client-side React state management library, no UI kit, no Tailwind — the panel is hand-rolled CSS modules with CSS custom properties for theming. The cyberpunk aesthetic is 100 % CSS animations + radial gradients, no images.
Issues and PRs welcome. See CONTRIBUTING.md for the house style. Quick start:
- Run the local build:
cd apps/web && npm install && npm run build. - Test the streamer locally:
cd apps/streamer && npm install && node src/index.js(you'll needINTERNAL_API_TOKENand a stream key in your env). - Keep the architecture: web is stateless beyond the SQLite/branding volume; streamer is the only place that should touch FFmpeg or Chromium.
MIT — do what you want, just don't sue me.
Made for late-night dashboard streams and lo-fi radio.
If this saved you from setting up OBS, ⭐ the repo.