- TypeScript 80.5%
- Vue 14%
- CSS 3.5%
- Dockerfile 1.4%
- HTML 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| .vscode | ||
| public | ||
| server | ||
| shared | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| env.d.ts | ||
| eslint.config.ts | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.app.json | ||
| tsconfig.json | ||
| tsconfig.node.json | ||
| vite.config.ts | ||
Twitch Relay
A self-hosted Twitch relay with a dashboard: OBS pushes RTMP into this server, which relays the stream on to Twitch over a single persistent connection. If OBS ever disconnects while armed, the server automatically switches Twitch over to a synthesized "Stream Disconnected" placeholder scene, and switches back the instant OBS reconnects — Twitch never sees the channel go dark or the connection drop, just the picture changing. The dashboard shows live status, a realtime preview of whatever's currently going out, and a single Start/Stop control.
Designed to run on a Tailscale tailnet with no dashboard login — the tailnet membership is the auth.
How it fits together
OBS --RTMP--> this server --RTMP--> Twitch
|
v
Vue dashboard (status + live preview + Start/Stop)
- Start arms the relay: nothing is pushed to Twitch until you click it.
- While armed, OBS connected → your real stream goes out; OBS disconnected → the placeholder goes out instead, automatically, after a short debounce.
- Stop disarms the relay and stops pushing anything to Twitch.
Prerequisites
- A Twitch stream key (Creator Dashboard → Settings → Stream). Keep it secret — it's the only credential this project needs.
- Docker and Docker Compose, for the recommended setup.
- A network path from wherever OBS runs to this server's RTMP port (
1935by default) — typically Tailscale, since there's no dashboard login and RTMP itself isn't encrypted.
Quick start (Docker)
- Clone this repo onto the machine that will run the relay (it needs to be reachable from wherever OBS runs).
- Copy the env file and fill in your stream key:
Opencp .env.example .env.envand setTWITCH_STREAM_KEY. Everything else in there has a working default — see Environment variables below if you want to change ports or other behavior. - Build and start it:
docker compose up --build -d - Open the dashboard at
http://<host>:8080(use the Tailscale hostname or IP for<host>). You should see it reachable but not yet armed — nothing has been pushed to Twitch yet. - Point OBS at the ingest (see OBS setup below) and start streaming in OBS. The dashboard's "OBS" indicator should flip to connected, and the live preview should show your feed.
- Click Start on the dashboard. Twitch should go live within a couple seconds.
- To confirm the failover works: stop streaming in OBS (or just kill its network connection) — within a few seconds the dashboard should flip to "fallback" and Twitch should show the "Stream Disconnected" placeholder instead of cutting out. Reconnecting OBS should switch it back automatically.
- Click Stop when you're done — this stops pushing anything to Twitch until you arm again.
To update to a newer version later: git pull, then docker compose up --build -d again.
To watch logs: docker compose logs -f. The dashboard also has its own /api/logs (rendered in
the UI) with the last ~200 lines from the relay/fallback/broadcaster ffmpeg processes, which is
usually more useful than the container logs for anything ffmpeg-related.
OBS setup
In OBS's Stream settings, choose Custom... as the service and fill in:
| OBS field | Value |
|---|---|
| Server | rtmp://<tailnet-hostname-or-ip>:1935/live |
| Stream Key | whatever RTMP_STREAM_KEY is set to (default: stream) |
Both values are also shown directly on the dashboard (composed from the page's own hostname),
with a copy button next to each — use those rather than typing them by hand if you're not sure
what <tailnet-hostname-or-ip> should be.
These are two separate OBS fields — don't paste the whole ingest URL into the Stream Key field or concatenate them.
Set OBS's own output (resolution/framerate/bitrate) to whatever you actually want on Twitch: the
real relay path is a pure -c copy remux with no transcoding, so OBS's settings are the
broadcast quality. (The synthesized fallback placeholder is currently tuned for 1080p60 — see
ENCODE_ARGS in server/ffmpeg/commands.ts if your OBS output differs and you want the
fallback to visually match.)
Environment variables
See .env.example for the full list, each with an explanatory comment. The
only one you must set is TWITCH_STREAM_KEY — everything else has a sane default:
| Variable | Default | Purpose |
|---|---|---|
TWITCH_STREAM_KEY |
(none) | Required to arm. Never sent to the frontend in full. |
RTMP_PORT |
1935 |
Port OBS publishes into. |
RTMP_APP / RTMP_STREAM_KEY |
live / stream |
Form the local ingest URL's app/key path. |
HTTP_PORT |
8080 |
Dashboard + REST API + WebSocket, all on one port. |
FALLBACK_VIDEO_PATH |
(none) | Optional custom video to loop instead of the synthesized scene. |
RECONNECT_DEBOUNCE_MS |
3000 |
How long OBS must stay disconnected before switching to fallback. |
OBS_IDLE_TIMEOUT_MS |
10000 |
How long a silent (no-FIN) OBS connection is tolerated before it's treated as dead. |
CRASH_LOOP_MAX / CRASH_LOOP_WINDOW_MS |
5 / 60000 |
Auto-disarm guard if ffmpeg keeps crashing. |
Changing any of these requires restarting the container (docker compose up --build -d again;
no rebuild needed unless you changed code, just a restart) — they're read once at process start.
Local development
npm install
npm run dev:all # frontend (Vite, :5173, proxies /api and /ws) + backend (:8080) together
Open http://localhost:5173. Requires ffmpeg on your PATH locally if you want to actually
exercise arming/relaying outside Docker — without it, the dashboard still loads but arming will
fail.
Other scripts:
npm run build— type-check, then build both frontend (dist/) and backend (dist-server/).npm run type-check—vue-tscfor the frontend +tsc --noEmitfor the backend.npm run lint— ESLint, autofixing what it can.npm start— run the already-built backend (dist-server/server/index.js), serving the built frontend statically. Used by the Docker image; rarely needed directly in dev.
Troubleshooting
- Dashboard loads but "Start" is disabled / arming fails immediately —
TWITCH_STREAM_KEYisn't set. Check.env(Docker) or your shell environment (local dev), then restart. - OBS won't connect — double check the Server/Stream Key split above (a common mistake is
swapping or concatenating them), that
1935/tcpis actually reachable from OBS's machine (e.g. both are on the tailnet), and that nothing else on the host is already bound to that port. - Dashboard shows OBS connected but nothing happens on Twitch — make sure you actually clicked Start; connecting OBS alone doesn't push anything to Twitch until the relay is armed.
- It fell back to the placeholder and won't come back — check
/api/logsin the dashboard first. A crash-looping ffmpeg auto-disarms after a few failures (CRASH_LOOP_MAX) rather than retrying forever — a bad/expiredTWITCH_STREAM_KEYis a common cause, since Twitch rejects the connection immediately. docker compose upfails to bind ports — something else on the host already owns1935or8080; changeRTMP_PORT/HTTP_PORTin.env, or stop the other process.
Deployment notes
- No dashboard auth by design — the trust boundary is Tailscale network membership, not a
login. Don't expose the dashboard port on a network you don't trust; see the commented-out
Tailscale sidecar block in
docker-compose.ymlif you want the container itself on the tailnet rather than relying on the host already being on it. - CI is Forgejo Actions (
.forgejo/workflows/ci.yml) — lints, type-checks, builds, and (onmain) publishes a Docker image. Needs instance-specific runner labels and registry credentials filled in (vars.REGISTRY,secrets.REGISTRY_USER/REGISTRY_TOKEN, and aruns-on:label matching a runner configured on your Forgejo instance).