Skip to content

Relay and self-hosting ​

owp-relay is the Open Watch Party relay. It is one Go binary that holds the rooms, runs their playback state machine, enforces the limits, saves state and serves the endpoints of the protocol.

Rooms live in memory. Give the relay a state directory and they survive restarts.

Use this guide when you run a relay: on your own machine while you build and test an app, or on a server for your users. Self-hosting covers the server, and the sections after it are the reference.

Run it locally ​

  1. In relay/, start the relay:

    sh
    go run ./cmd/owp-relay --addr 127.0.0.1:8080

    It prints listening 127.0.0.1:8080 when it is ready.

  2. Point your app at it. The WebSocket is at ws://127.0.0.1:8080/v1/ws. An SDK takes the base URL instead: Relays.at("http://127.0.0.1:8080").

  3. Check that both listeners answer:

    sh
    curl http://127.0.0.1:8080/health
    curl http://127.0.0.1:9090/metrics

Self-hosting ​

relay/compose.yaml runs one relay of your own from the published image ghcr.io/edde746/owp-relay. The relay terminates TLS itself, with a Let's Encrypt certificate that it gets and renews on its own.

A self-hosted relay serves any app. Its rooms are separate from the public relays' rooms.

What you need ​

  • Docker Compose.
  • An A record for your hostname, and an AAAA record as well if the server has IPv6.
  • TCP port 443 open. Port 80 can stay closed.

Start it ​

  1. Next to compose.yaml, create a .env file with your hostname:

    OWP_DOMAIN=relay.example.com

    Without it, Compose stops with set OWP_DOMAIN in .env.

  2. Start the relay:

    sh
    docker compose up -d

    It listens on port 8443 in the container, published as port 443 on the host.

  3. Point your apps at it. With its base URL, the SDK uses this one relay for everything (SDK.md §3):

    SDKCode
    TypeScript, DartRelays.at("https://relay.example.com")
    KotlinRelays.at(http, "https://relay.example.com")
    Swifttry Relays.at("https://relay.example.com")

Check on it ​

On the host, these show the metrics and the logs:

sh
curl http://127.0.0.1:9090/metrics
docker compose logs relay

Compose keeps the logs in 5 files of 10 MB each.

compose.yaml is sized for the default of 10000 connections, at about 60 KiB each. To allow more, set OWP_MAX_CONNECTIONS, then raise mem_limit (now 1 GiB) and GOMEMLIMIT in proportion.

Update it ​

The image tag 1 tracks the latest 1.x.y release. To update, pull the new image and restart:

sh
docker compose pull && docker compose up -d

The rooms come back from the saved state.

Keep the relay-state volume across updates. A new volume orders a new certificate, and Let's Encrypt allows 5 per exact set of names per 7 days.

Don't back the volume up. Its snapshots carry names and descriptors that should vanish with their room. Losing the volume loses the live rooms, the daily usage counts and the ACME account key.

Without Docker ​

Run the binary as a user who may bind port 443, with a state directory DIR of your choice:

sh
owp-relay --addr :443 --tls-domains relay.example.com --state-dir DIR

Configuration ​

Every flag has an environment variable. The variable replaces the default, and a flag on the command line wins over both. /v1/info reports the limits and policy in force.

The values follow these rules:

  • Booleans take the values Go's strconv.ParseBool accepts, such as true, false, 1 and 0.
  • Each limit must be at least 1, and each rate must be positive.

It exits with one of these statuses:

ExitWhen
0After --help, or after a clean shutdown.
1It refused the configuration, or a listener failed.
2A flag, an argument or an environment variable has an error.

General ​

FlagVariableDefaultWhat it does
--addrOWP_ADDR:8080The public listener's address.
--admin-addrOWP_ADMIN_ADDR127.0.0.1:9090The admin listener's address. Empty disables it.
--idOWP_IDlocalThe relay ID: 1-32 characters of a-z 0-9 -, starting with a letter or digit.
--prefixesOWP_PREFIXESall 31The first characters of the codes it creates: 1-31 distinct code symbols.
--denylistOWP_DENYLISTnoneA file of app names to refuse, re-read when it changes.
--state-dirOWP_STATE_DIRnoneWhere it keeps snapshots, usage counts and ACME certificates.
--trust-proxyOWP_TRUST_PROXYfalseTakes the client IP from the first X-Forwarded-For hop.
--timer-scaleOWP_TIMER_SCALE1Test mode: multiplies the scaled policy durations (SPEC.md §13.3). Must be positive.

Connection and room limits ​

FlagVariableDefaultWhat it does
--max-connectionsOWP_MAX_CONNECTIONS10000Connections in total. Any more get 503.
--max-participantsOWP_MAX_PARTICIPANTS8Participants per room, disconnected ones included. From 1 to 20.
--max-connections-per-ipOWP_MAX_CONNECTIONS_PER_IP10Connections per IPv4 address or IPv6 /64. Any more get 429.
--max-roomsOWP_MAX_ROOMS10000Rooms the relay holds. A create beyond it gets relay_full.
--max-rooms-per-ipOWP_MAX_ROOMS_PER_IP3Rooms that one IPv4 address or IPv6 /64 created and the relay still holds.
--ipv6-factor-56OWP_IPV6_FACTOR_564The per-IP multiplier for an IPv6 /56.
--ipv6-factor-48OWP_IPV6_FACTOR_4816The per-IP multiplier for an IPv6 /48.

The per-IP limits count by address group. An IPv4 address is a group on its own. An IPv6 address counts in its /64 at the limit as given (×1), and in its /56 and /48 at the limit times the factors. A request must fit every group it is in.

With --tls-domains, --max-connections-per-ip also counts the sockets that haven't upgraded to a WebSocket yet. A socket beyond the limit is closed at once.

Message and join rates ​

These limits are token buckets. Each has a burst size and a refill per second.

FlagVariableDefaultWhat it does
--message-burstOWP_MESSAGE_BURST30The size of each connection's message bucket.
--messages-per-secondOWP_MESSAGES_PER_SECOND10Its refill. A message that finds no token gets rate_limited.
--join-burstOWP_JOIN_BURST20The size of each IP's join bucket.
--joins-per-secondOWP_JOINS_PER_SECOND1The per-IP join refill.
--join-burst-totalOWP_JOIN_BURST_TOTAL2000The size of the relay-wide join bucket.
--joins-per-second-totalOWP_JOINS_PER_SECOND_TOTAL200The relay-wide join refill.

Only join uses the join buckets.

TLS ​

FlagVariableDefaultWhat it does
--tls-domainsOWP_TLS_DOMAINSnoneHostnames for ACME TLS on --addr. Needs --state-dir.
--acme-directoryOWP_ACME_DIRECTORYhttps://acme-v02.api.letsencrypt.org/directoryThe ACME directory. Must be https://.
--acme-emailOWP_ACME_EMAILnoneThe ACME account's contact.

TLS and proxies explains how the relay serves TLS.

Endpoints ​

The relay has two listeners: the public one on --addr and the admin one on --admin-addr.

ListenerEndpointWhat it does
--addrGET /v1/wsThe WebSocket. It accepts any Origin.
--addrGET /v1/infoJSON with protocol 1, id, limits and policy, sent with CORS * and Cache-Control: no-cache.
--addrGET /healthAnswers 200 ok.
--admin-addrGET /metricsThe metrics as Prometheus text.
--admin-addrGET /admin/usageThe daily usage counts as JSON.

/admin/usage answers {"id":…,"days":{"YYYY-MM-DD":{"<app>":count}}}.

The admin listener is plain HTTP and unauthenticated, so keep it local. TLS wraps only --addr.

SPEC.md Appendix B lists the close codes and the HTTP refusals at upgrade.

Deny list file ​

The deny list file names the apps the relay refuses, one per line. An app's name is the part of its client before the first /.

# Apps this relay refuses, one per line
ExampleApp

The relay reads the file like this:

  • It trims each line and matches names ASCII case-insensitively.
  • It ignores blank lines and lines starting with #.
  • It re-reads the file when it changes.
  • A missing file is an empty list.
  • If the file can't be read, it keeps the previous list and logs the error.

A create, join or resume from a listed app gets client_blocked. Participants already in rooms stay.

State and restarts ​

With --state-dir, rooms survive a restart. Without it, nothing survives.

What the relay saves ​

The relay creates the state directory with mode 0700 and keeps two files in it:

  • rooms.json holds all rooms. It stores tokens only as SHA-256 hashes and holds no IPs, but names, clients and descriptors are in it.
  • usage.json holds the daily usage counts.

The relay rewrites rooms.json within 4 seconds of a change, and every 4 seconds while a room plays. A crash therefore loses at most 5 seconds.

Shutdown and restart ​

On SIGTERM or SIGINT, the relay shuts down in this order:

  1. It stops accepting connections.
  2. It saves its state.
  3. It closes all connections with code 1001.
  4. It waits up to 2 seconds for them to close.
  5. It exits with status 0.

On restart, it restores every room created less than 24 hours ago:

  • A room with media comes back paused at the saved position.
  • A room without media comes back idle.
  • Its participants come back disconnected, and they can resume.

A room is deleted once its last participant is gone. A restored room therefore lasts until its participants resume or their reservations run out.

Metrics, usage and logs ​

The admin listener's /metrics serves these metrics:

MetricTypeWhat it reports
owp_build_info{version}GaugeThe relay build. The value is always 1.
owp_connectionsGaugeOpen WebSocket connections.
owp_roomsGaugeRooms the relay holds.
owp_participants{connected}GaugeParticipants in rooms, by whether a connection is attached.
owp_participants_by_client{client}GaugeParticipants per app name: the day's top 50 names, and the rest as (other).
owp_rooms_created_totalCounterRooms created.
owp_joins_totalCounterAccepted joins.
owp_resumes_totalCounterAccepted resumes.
owp_messages_totalCounterWebSocket data messages received.
owp_rejected_connections_total{reason}CounterWebSocket upgrades refused by a connection limit.
owp_errors_total{code}CounterError messages sent, by code.

The usage counts record accepted creates, joins and resumes per UTC day and app. The relay keeps them for 400 days. They hold no IPs, names, codes or titles.

The relay prints the listening line to stdout and logs everything else to stderr, with UTC times. It never logs codes, tokens, names, clients, descriptors, message content or client addresses, and it doesn't log failed TLS handshakes.

TLS and proxies ​

The relay can serve TLS itself, or it can sit behind a proxy that terminates TLS for it.

Serving TLS itself ​

With --tls-domains, --addr serves TLS itself. The relay doesn't listen on port 80 or redirect to TLS, and it leaves plain HTTP unanswered.

Each name must be an ASCII DNS name with two or more labels. A wildcard, a trailing dot or an IP address is refused, and so is localhost.

The certificates come over TLS-ALPN-01 on TCP port 443. The name must therefore resolve to this server, and port 443 must reach --addr. The certificates live in <state-dir>/acme.

The relay renews the certificates automatically. It logs each certificate's expiry after it starts, and then daily. To test, point --acme-directory at a test CA, such as Let's Encrypt's staging environment or Pebble.

At most 1024 sockets wait for a handshake or a request at once. Each address group holds no more than its --max-connections-per-ip share of them. A request that doesn't upgrade to a WebSocket ends its connection.

Behind a proxy ​

Behind a proxy, leave --tls-domains unset. The proxy terminates TLS and forwards /v1/ws with the upgrade, /v1/info and /health, keeping the CORS header. The relay's 30-second pings fit within nginx's default timeout of 60 seconds.

Use --trust-proxy only if the proxy overwrites X-Forwarded-For with the client's address. In nginx, that is proxy_set_header X-Forwarded-For $remote_addr;, together with the standard upgrade headers. With --trust-proxy, a request whose first X-Forwarded-For value can't be parsed gets 400.

Proxies must not log IPs either (SPEC.md §15).

To run the Docker image behind a proxy, mount a volume at /state and set OWP_STATE_DIR=/state. The image runs as UID 65532.

Build and test ​

You need Go 1.26 or later. To build a static binary, run this in relay/:

sh
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=1.0.0" -o bin/owp-relay ./cmd/owp-relay

Without -X main.version, the version is dev.

To build the image, run this from the repository root. The image is distroless and runs as user 65532.

sh
docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION=1.0.0 -t ghcr.io/edde746/owp-relay relay

To check and test the relay:

sh
cd relay
gofmt -l .
go vet ./...
go test -race -count=1 ./...

gofmt -l . must print nothing.

For the full protocol check, see the conformance suite. See also the integration guide and SECURITY.md.