Skip to content

Self-hosting

Signaling servers

Run your own PeerJS server so rooms don't depend on the public one, and point rooms at it with ?signal= or a server list.

A signaling server only introduces a room’s devices to each other. By default rooms use the public PeerJS cloud (0.peerjs.com), which is free and shared, so it can be slow, down or blocked. Run your own for reliable rooms, and to keep room codes and IP addresses off a third-party server.

Shortest path: run npx peer --port 9000 --path / behind TLS, then open rooms with ?signal=signal.example.com.

The host registers the room code; a joiner asks for it, and the two swap WebRTC offers through the server. After that the links are direct (or through a TURN relay). Prompts, answers and the model never touch it. A running room keeps going if signaling drops, but new devices can’t join and a reloading host can’t reclaim its room until it’s back.

Any PeerJS server 1.x works. The repo already has one as a dev dependency (peer).

npx peer --port 9000 --path /
# listening on ws://<this machine>:9000

An https page can only open wss://, so the server needs TLS. Give it a certificate:

npx peer --port 9000 --path / --sslkey key.pem --sslcert cert.pem

Or run it behind a reverse proxy and pass --proxied. With Caddy:

Caddyfile
signal.example.com {
reverse_proxy 127.0.0.1:9000
}
npx peer --port 9000 --path / --proxied
curl https://signal.example.com/peerjs/id

It should print a fresh id (a UUID). With a non-default --key, the path is /<key>/id.

Add ?signal= to the room URL. The host and every joiner must use the same server, because a room registered on one server doesn’t exist on another. The host’s invite link and QR code carry the parameter for you.

https://pooled.run/room?signal=signal.example.com wss, port 443, path /
https://pooled.run/room?signal=wss://signal.example.com:8443/pooled
https://pooled.run/room?signal=signal.example.com,cloud yours first, then the cloud
http://127.0.0.1:8080/room?signal=127.0.0.1:9000 local dev: ws, since the page is http

?signal= replaces every other setting: only the servers it names are tried, in order.

Form Example Meaning
cloud cloud The public PeerJS server, 0.peerjs.com
host signal.example.com wss when the page is https (ws when http), port 443, path /
host:port 127.0.0.1:9000 Same, on that port
host:port/path signal.example.com:443/pooled Same, under that path
URL wss://signal.example.com:8443/pooled wss://, ws://, https:// or http://

Without a port, a bare host uses 443; a URL uses 443 for wss:///https:// and 80 for ws:///http://.

You can list several servers (room/signal.js):

  • The host uses the first server that registers the room code. A server counts as down when it refuses, drops, fails TLS or doesn’t answer within 8 s (20 s for the last one). “That code is already hosting a room” is an answer, not an outage, so it never falls back.
  • A joiner walks the same list, and tries the next server when one has no such room. An invite link goes straight to the host’s server: it carries ?signal= (and dev=0) whenever the host isn’t on the first server.
  • A host that reloads tries its room’s server first, because its guests are there.
  • All servers down: the join screen says so, running rooms are not affected, and you can retry.
  • Drops during a room: every device shows a note under the header and re-registers with backoff (2, 4, 8, 16 s, then every 30 s). Running rooms keep working; new devices can’t join until it’s back.

Signaling is not part of the room protocol, so devices on different Pooled versions still meet on the same server.

No GPU needed:

deno test --allow-read tests/unit/signal_test.js # spec parsing, list order, fallback rules
node tests/e2e/signal_fallback.mjs # the fallback end to end in headless Chromium