Watch videos together, in sync. Create a room, queue links from YouTube and other sources, pick your own quality, and let the server keep everyone on the same frame.
- Ingest — a separate worker process downloads the selected renditions
with
yt-dlp, remuxes them into DASH segments withffmpeg -c copy(no transcoding) and uploads the result to S3-compatible storage. - Delivery — the web server proxies segments from S3 behind short-lived HMAC tokens; the browser plays them with Shaka Player and can switch quality at any time.
- Sync — the server holds the authoritative playback clock and pushes it
over WebSocket; clients estimate their clock offset and nudge
playbackRateto converge, seeking only on large drift. - Rooms — public or private (invite by username), owner and moderators control playback, optional vote mode for skipping and reordering, chat, per-room bans, site administrators with a reports panel.
No WebRTC: the video is prerecorded, so synchronised DASH playback is simpler, cheaper and more robust than real-time media transport.
The host above, a guest below: the guest's player follows pause, seek and play, and chat reaches everyone.
Shown: Sintel and Big Buck Bunny © Blender Foundation, CC BY 3.0.
just demo-media films both images again from a throwaway stack.
Requirements: Docker and just.
cp .env.example .env # set COUCHCAST_MEDIA_TOKEN_SECRET for production
just up # Postgres + SeaweedFS (S3)
just migrate # apply schema migrations
just dev # web server, ingest worker, Vite dev serverOpen http://localhost:5173. The SeaweedFS filer UI (stored
files) is at http://localhost:8888; S3 itself is on :9000 with
S3_ACCESS_KEY / S3_SECRET_KEY from .env.
Tests: just test (Go with the race detector, against a test database
and SeaweedFS), just test-web (Vitest unit tests for the web app),
just lint, just check (TypeScript) and just e2e (Playwright in the
browser against a separate couchcast_e2e database, including an axe
accessibility scan). just cover prints Go coverage, as the badge.
just load runs a load test against the dev stack; results and limits
are in docs/load.md.
Production uses the same compose file without the development override and a released image (see Releases):
just deploy 2026.9.0 # a release; or build from source: just build && just prod-migrate && just prod-upPhones need HTTPS for notifications and for installing the app, and
cookies are Secure by default. The https compose profile puts
Caddy in front of the web server and gets the
certificate automatically. Security headers (CSP, HSTS, framing) come
from couchcast itself, so they are the same with or without Caddy.
-
Point a DNS name at the server and open ports 80 and 443.
-
In
.env(start from.env.example) set at least:COUCHCAST_DOMAIN=watch.example.com COUCHCAST_MEDIA_TOKEN_SECRET=... # openssl rand -hex 32 COUCHCAST_TRUST_PROXY=true # Caddy sets X-Forwarded-For WEB_PORT=127.0.0.1:8080 # the web server only via Caddy POSTGRES_PASSWORD=... S3_SECRET_KEY=... # optional: COUCHCAST_VAPID_* from `docker run --rm <image> vapid`
-
Deploy a release (it checks out the release's tag, pulls its image, applies migrations and starts everything behind Caddy):
just deploy 2026.9.0 just prod-admin-grant <your-username> # after registering
To update, just deploy <newer version>; migrations are never applied on
boot. just prod-down stops everything; data lives in the pgdata,
s3data and caddydata volumes.
Versions are calendar based, vYYYY.M.N (N counts the releases within
the month), and listed in CHANGELOG.md. Each release
publishes ghcr.io/anesthetised/couchcast:<version> for linux/amd64 and
linux/arm64; YYYY.M and latest follow the newest one. A new GHCR
package is private: make it public in its settings on GitHub, or run
docker login ghcr.io on the server.
- Cut a release:
just releaseon an up-to-date main writes the changelog, commits it and tags the next version;git push origin main <tag>publishes it. The release workflow waits for CI on that commit and publishes only if it passed. - Deploy:
just deploy <version>checks out the tag, so the compose files and migrations match the image, pulls it, migrates, starts (behind Caddy whenCOUCHCAST_DOMAINis set) and recordsTAGin.env. - Roll back:
just deploy <older version>works while no migration came in between. Otherwise it refuses, because the older server would run against a newer schema: restore a backup taken before the upgrade (just restore), then deploy the older version. Back up before an upgrade that brings migrations.
just backup writes backups/<UTC time>/ with a Postgres dump
(postgres.dump) and an archive of the object storage volume
(s3data.tar.gz); SeaweedFS is paused for the seconds the archive takes,
so playback stalls briefly. just restore backups/<time> puts both back
after a confirmation, stopping the web server and the worker meanwhile.
Media is a cache that can be downloaded again; the database is what must not be lost. Run a backup from cron and copy the directory off the machine, for example nightly:
0 4 * * * cd /srv/couchcast && just backup && rsync -a --remove-source-files backups/ backup-host:couchcast/just backup-check proves the recipes on a throwaway stack (CI runs it on
every push); a real backup counts once it has been restored somewhere: bring the stack up
under another project name (COMPOSE_PROJECT_NAME=couchcast-check just prod-up), run just --yes restore <dir> there, and look around.
Grant the first administrator from the shell, on a server running a
release (prod-admin-revoke takes the role away again):
just prod-admin-grant <username>In development, just admin-grant <username> does the same from source.
Administrators get an Admin link in the header with statistics, media reports (dismiss, or delete the video and block its source), user bans, room deletion, the blocklist and the audit log.
cmd/couchcast/ entrypoint: serve | ingest | admin
internal/ Go packages (config, apihttp, metrics, ...)
web/ SolidJS frontend, embedded into the binary at build time
db/ schema.sql (source of truth) and Atlas migrations
deploy/ files mounted into infrastructure containers
- Two processes, one image.
couchcast serve(web) andcouchcast ingest(worker) share PostgreSQL and S3 and nothing else, so the worker can run on a machine with a residential IP while the web server sits on a VPS. Several workers can run at once. - Migrations are applied explicitly (
just migrate/just prod-migrate), never on boot.db/schema.sqlis the source of truth; Atlas generatesdb/migrations. - Cache budget.
COUCHCAST_MAX_CACHE_BYTEScaps packaged media in S3; every 30 minutes the least recently watched items that no room has queued are evicted. Ingest attempts upload to separate prefixes; only the package published in PostgreSQL is served. The same periodic pass removes abandoned attempt packages older than an hour, preserving published packages and running leases even when the cache budget is disabled. Chat older thanCOUCHCAST_CHAT_RETENTION_DAYSis purged hourly. - Metrics are exposed at
/metricson the web server and onCOUCHCAST_INGEST_METRICS_ADDRon the worker (Prometheus format). - YouTube may require cookies or a PO-token provider on some networks;
pass extra flags through
COUCHCAST_YTDLP_EXTRA_ARGS. - Private addresses. Links whose host is or resolves to a loopback,
private, CGNAT, link-local (cloud metadata) or unique-local address are
refused, and the worker fetches posters only from public addresses. To
queue files from your own LAN, set
COUCHCAST_ALLOW_PRIVATE_SOURCES=trueon bothserveandingest— only on an instance where everyone who can add videos is trusted. yt-dlp resolves names and follows redirects on its own, so the check is not airtight: the compose file therefore gives the ingest worker networks of its own with only PostgreSQL, S3 and the internet; keep it that way on other setups.
A room that is playing stays loaded, keeps advancing through its queue and is resumed after a server restart. By default it pauses two minutes after the last viewer leaves ("Pause when everyone leaves" in the room options); turned off, it plays on for nobody. The directory marks playing rooms as live; the viewer count is shown separately.
- The web server is a single instance: room state lives in memory. Run
as many ingest workers as you like, but exactly one
serve. - Media segments are protected by short-lived HMAC tokens bound to a media id, not to a viewer: anyone who obtains a token can fetch that media until it expires (one hour).
- Supported browsers in v1: desktop Chrome/Firefox/Edge and Android Chrome. Safari/iOS needs an HLS output that is not implemented yet.
- Playback state is not sharded; a room with hundreds of viewers is fine, thousands of rooms with live viewers is not what this is built for.
MIT

