Self-hosted, open-source room and playback synchronization server for watch-together clients. It is application-agnostic: clients exchange room membership, content identity, playback state, buffering state, and clock pings over WebSocket. Video and audio never pass through this server.
Create a .env file next to docker-compose.yml:
WATCH_TOGETHER_SECRET=replace-with-a-long-random-token
WATCH_TOGETHER_ALLOWED_ORIGINS=https://your-client.example
TRUST_PROXY_HEADERS=trueThen start the server:
docker compose up -d --buildThe server listens on port 8787 by default. The supplied Compose file binds it to loopback so Internet traffic must pass through the TLS reverse proxy.
Environment variables:
WATCH_TOGETHER_SECRET: shared token required during the WebSocket handshakeALLOW_ANONYMOUS: set totrueonly for intentionally public, unauthenticated instances; defaultfalseWATCH_TOGETHER_ALLOWED_ORIGINS: comma-separated browser origins, for examplehttps://app.example.com,https://another.example; native clients without anOriginheader are allowedTRUST_PROXY_HEADERS: set totrueonly when a trusted reverse proxy overwritesX-Forwarded-FororX-Real-IP; defaultfalseMAX_ROOM_MEMBERS: maximum members per room, default12ROOM_TTL_MINUTES: inactive room lifetime, default360PORT: listen port, default8787MAX_CONNECTIONS: server-wide concurrent WebSocket limit, default1000MAX_CONNECTIONS_PER_ADDRESS: concurrent WebSocket limit per remote address, default20MESSAGES_PER_SECOND: per-connection sustained message limit, default30MESSAGE_BURST: per-connection burst limit, default60
Health check:
GET /health
WebSocket endpoint:
/ws
Clients connect with ?token=.... The token is required unless ALLOW_ANONYMOUS=true. Because browser WebSocket APIs cannot set arbitrary request headers, deploy behind TLS and avoid logging query strings at the reverse proxy.
Put the server behind TLS using Caddy, Nginx, or Traefik. Browsers require secure WebSockets when the client is loaded over HTTPS.
Example Caddy configuration:
watch.example.com {
reverse_proxy 127.0.0.1:8787
}
Clients then connect to:
wss://watch.example.com/ws
The protocol is JSON over WebSocket.
Client messages:
create: create a room withnamejoin: join a room withroomandnameleave: leave the current roomstate: host playback statecontent: host content identitybuffering: member buffering stateping: client clock synchronization
Server messages:
room: assigned room, client ID, host ID, and membersmembers: current membership and hostsync: host playback state with sequence and server timestampcontent: current content identitybuffering: aggregate buffering notificationpong: server clock responseerror: request or room failure
The server sends WebSocket ping frames every 30 seconds and drops connections that go 90 seconds without traffic. Browsers answer ping frames automatically; native clients must reply with a pong. A client that cannot keep up with broadcasts is disconnected rather than allowed to delay the rest of its room.
Clients remain responsible for media playback, authorization UI, content resolution, drift correction, and reconnect behavior.
Use TLS for Internet deployments, configure WATCH_TOGETHER_SECRET, and set WATCH_TOGETHER_ALLOWED_ORIGINS to the exact client origins you trust. The server rejects browser origins that are not listed, limits concurrent connections, limits message rates, caps WebSocket frames, and expires inactive rooms. The server is intentionally stateless beyond active in-memory rooms; restarting it removes all rooms. For multiple instances, use sticky routing or add a shared room store and broker.
The desktop client can run watch together either against this server (mode: 'websocket') or directly against Supabase Realtime (mode: 'supabase'). In Supabase mode this server is not used at all and there is nothing to deploy: Realtime carries the broadcasts and presence, and the room registry, capacity, and quota rules live in Postgres.
Apply supabase/migrations/ to the project, then point the client at the project URL and anon key. The migration creates:
watch_roomsandwatch_room_members, readable only by members through RLS, writable only through the functions belowcreate_watch_room(), which allocates a collision-checked six-character code from the same ambiguity-free alphabet this server uses, caps a user at five rooms per hour and one active room, and purges expired roomsjoin_watch_room(), which enforcesmax_membersunder a row lock, andleave_watch_room(), which promotes the earliest remaining member to host or drops the room- RLS policies on
realtime.messagesso only current members of a live room can send to or receive fromwatch-together:<code>
Instance limits live in watch_settings, a single row holding max_members, room_ttl, and rooms_per_hour — the Supabase-mode counterpart to this server's MAX_ROOM_MEMBERS, ROOM_TTL_MINUTES, and rate-limit variables. It is readable by signed-in users and writable only through the SQL editor or the service role, so a self-hosted instance can raise the defaults without the client being able to. One active room per user is not a tunable: the client protocol assumes a single room per connection.
Rooms expire after room_ttl, six hours by default. create_watch_room() purges expired rows opportunistically; schedule watch_room_purge() with pg_cron if you want rooms collected without traffic.
Realtime bills a broadcast as one message sent plus one per subscriber that receives it, so a four-person room costs four messages per host update. At the client's ten-second cadence that is 1,440 messages an hour per room, or roughly 1,400 room-hours against the free plan's two million monthly messages. The per-second cadence the client used previously would have cost ten times that and exhausted the month in about 140 room-hours. Presence joins and leaves are billed the same way, so churn matters more than room size.
Two things the client must do for any of this to apply:
- Subscribe with
{ config: { private: true } }. Realtime only consults therealtime.messagespolicies for private channels; a public channel bypasses them entirely. - Call
supabase.realtime.setAuth(accessToken)with the signed-in user's token before subscribing, and create or join rooms through the RPCs rather than generating a code locally.
Known limit: RLS gates who may send in a room, not what they send. Host authority stays advisory — clients should ignore state and content messages whose sender is not the host_id they read from watch_rooms. Enforcing that server-side would require routing playback state through a function instead of broadcast, which costs the latency Realtime is being used for.
These policies are written as the only policies on realtime.messages. Policies are permissive and OR together, so other Realtime features need their own; adding this one does not grant them access, but if it is the only policy present then every other topic is denied.
MIT. See LICENSE.