webstation-broker
Deployment

Reverse proxy

Serving the container from a subfolder of RomM's origin, with recipes for nginx, Nginx Proxy Manager, Caddy and Traefik.

The room UI shows the selkies stream in an iframe (a webpage embedded inside another webpage), and RomM embeds the whole room the same way. When that outer iframe is cross origin, meaning it comes from a different address than RomM itself, the browser stops passing mouse movement through to the page around it the moment the cursor enters the frame. RomM's player can no longer see the mouse at all, so it has to overlay a strip of its own along the bottom edge to show its control bar, and that strip sits on top of the container's own taskbar.

Serving the container from a subfolder of RomM's own origin fixes this: the player can track the mouse inside the frame, nothing overlays the stream, and the whole container desktop becomes clickable.

Skipping this also breaks the room's camera, microphone and invite-link copy button, not just pointer tracking.

On the cross-origin path (a full URL in host, not a subfolder), three things fail with no error message pointing back here:

  • Camera and microphone are refused instantly. getUserMedia, the browser's built-in API for requesting camera or microphone access, fails before the browser ever shows a permission prompt.
  • The invite buttons can't copy the link automatically and fall back to a manual copy prompt instead.
  • Pointer tracking is lost inside the frame, which is what the edge-strip overlay above works around.

All three have the same root cause, and there is no proxy-side fix: the browser's Permissions-Policy (a built-in browser rule controlling which permissions a page can hand to the frames inside it) defaults to same-origin only for camera, microphone, and clipboard-write, and RomM's iframe cannot grant those permissions to a frame from a different origin. Websocket forwarding, exploit blocking, and every other proxy setting are irrelevant here. This is the browser treating the room as a different site, full stop.

Serving the container from a subfolder of RomM's own origin, below, is the only fix, and it removes the restriction entirely.

RomM does not proxy any of this itself. A reverse proxy sitting in front of both services maps a path on RomM's origin to the container. The container is already built to run behind one.

The contract

Three rules. Skip any one of them and the setup breaks.

SUBFOLDER must equal the mount path exactly, trailing slash included.

The app writes its own file links (like /streaming/assets/...) as absolute paths, and builds its websocket address (the live connection the stream and room chat run over) from the browser's current address plus that same prefix. Set SUBFOLDER and your proxy's mount path to the exact same value, and everything lines up automatically. Nothing needs rewriting.

The proxy must pass the prefix through, not strip it.

This is the rule that trips people up most. Most reverse proxies offer a strip-prefix mode, and several turn it on by default. Strip the prefix here, and the page will load, then every asset it asks for comes back as a 404 (a "not found" error) instead.

The proxy must forward websocket upgrades.

The stream, the collab room, and the input channel are all websockets. Without the upgrade headers the room loads and then sits there dead.

Container side

SUBFOLDER=/streaming/

That is the whole container-side configuration. Inside the container, its own nginx (a small web server) serves the room UI, the broker API, and the selkies stream, all under that one prefix, so a single proxy rule covers all three. Point your reverse proxy at port 3000, which serves plain HTTP, and use only that port for everything: the proxy, broker_host, and any curl checks. Your reverse proxy handles HTTPS for browsers (often called TLS termination), so the container never needs to.

For advanced users: port 3001

The container also listens on port 3001, which serves HTTPS with a self-signed certificate (one browsers and curl won't trust, since the container issued it itself). Streaming needs a reverse proxy in front regardless, so there is nothing port 3001 does that the proxy doesn't already do better. Don't publish it, and don't point a proxy or RomM at it unless you have a specific reason.

RomM side

In RomM's config.yml, a streaming container entry uses three keys for this:

streaming:
  containers:
    - platform: ps2
      # Where the browser goes. A path means "reverse proxied onto RomM's own
      # origin", and the browser resolves it against whatever origin it is on.
      host: /streaming
      # Where RomM's backend goes, server to server. Required when host is a
      # path, since a path carries no address RomM could call.
      broker_host: http://10.0.1.56:3000
      # The container's SUBFOLDER. RomM builds broker API paths under it.
      subfolder: /streaming
      broker_secret: "..."
      protocol: webstation
      emulator: pcsx2
      label: WEBSTATION

host can also be a full URL. What matters is the origin it names, not whether it is written as a path:

  • A full URL on RomM's own origin (https://romm.example.com/streaming, where RomM itself is served from https://romm.example.com) is the same deployment as the path form. The browser ends up at the same address either way, so everything on this page applies unchanged. The path in it still has to match subfolder.
  • A full URL on any other origin (https://webstation.example.com, or the same host on a different port or scheme) is the cross-origin deployment. RomM's player falls back to its edge-strip overlay automatically to make up for the lost pointer tracking, but camera, microphone, and automatic invite-link copying stay broken there for the reason above. There is no proxy-side fix for those.

broker_secret has to match the container's BROKER_SECRET. If you would rather keep it out of config.yml, set STREAMING_BROKER_SECRET in RomM's own environment instead. When that is set, RomM uses it for every streaming container and ignores each entry's broker_secret, so it only suits deployments where all containers share one secret.

When RomM and the container share a Docker network

If both run from the same Compose file, or are otherwise attached to a common Docker network, RomM's backend can reach the container by its service name. Point broker_host at that name and the container's own port 3000, not a port you published on the host:

broker_host: http://romm_webstation:3000

A published host port (3000:3000) is only reachable through the host's address, which a container on a bridge network often can't see. Once a reverse proxy and broker_host both reach the container over the Docker network, it needs no published ports at all.

Session identity is derived from broker_host, not host, so moving a container behind a proxy does not disturb a session it is already holding.

Proxy recipes

location /streaming/ {
    proxy_pass http://10.0.1.56:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_buffering off;
    proxy_read_timeout 3600s;
}

proxy_pass deliberately has no path after the address. Adding even a bare / makes nginx strip /streaming/ before forwarding the request, which breaks every asset the page tries to load. proxy_buffering off matters for the video stream, and the long read timeout keeps an idle session's websocket connection from being closed while a game is still running.

Verifying

curl -sS -o /dev/null -w '%{http_code}\n' https://romm.example.com/streaming/

Expect 200 (the standard "it worked" response). Keep the trailing slash: the container's nginx answers a bare /streaming with a 301 redirect ("moved here instead") to http://<host>:3000/streaming/. That redirect carries the container's own internal port, which is not reachable from wherever the browser is. Every URL handed to a browser should already end in the slash, so nothing has to follow that redirect.

Then fetch the page body and confirm the asset paths it references still carry the prefix:

curl -sS https://romm.example.com/streaming/ | grep -o 'src="[^"]*"'

Every src should start with /streaming/. If they start with / alone, the container's SUBFOLDER is not set. If they look right but return 404 when fetched, the proxy is stripping the prefix.

Last, confirm the broker answers through the same rule:

curl -sS https://romm.example.com/streaming/api/health

Running more than one container

The mount path is set per container. The app builds its prefix into every asset URL when it starts up, so there is no wildcard shortcut and no way to share one rule across a pool. Each container needs its own SUBFOLDER (/streaming/, /streaming-2/), its own proxy rule, and its own config.yml entry. Copy the proxy recipe you used above and change both paths in it.

streaming:
  containers:
    - platform: ps2
      host: /streaming
      subfolder: /streaming
      broker_host: http://10.0.1.56:3000
      # ...remaining keys as above
    - platform: ps2
      host: /streaming-2
      subfolder: /streaming-2
      broker_host: http://10.0.1.57:3000
      # ...remaining keys as above

subfolder and the path in host must both equal the container's own SUBFOLDER.

They answer to the same value for different reasons:

  • subfolder is where RomM's backend finds the broker API (broker_host + subfolder + /api/...). The container only serves it under its own SUBFOLDER, so a wrong value makes every claim fail with a 404 from the broker.
  • The path in host is where the browser starts, but the broker answers a claim with a room URL built from its own SUBFOLDER. That is an absolute path, so it replaces whatever path host carried.

The trap is copying the first container's setup for the second one. If the second container keeps SUBFOLDER=/streaming while the proxy and host say /streaming-2, RomM reaches it fine over broker_host, but the room URL it hands back is /streaming/..., and the proxy sends the player to the first container.

RomM catches the config half of this: an entry whose host path differs from its subfolder is refused, logged with both values, and shown in the fleet view as unclaimable. It cannot see the container's own SUBFOLDER, so keep the compose file, the proxy rule, and the config.yml entry in step by hand. A host that is a bare origin, with no path, is exempt.

How RomM picks one

Entries for the same platform are one pool when they agree on emulator, memory_card_sync, and protocol. Differing subfolder values do not split them, which is the whole point: same origin pooling puts every container at a path of its own.

When a player claims a container, RomM checks the pool in the order the entries appear in config.yml, and gives them the first one nobody is already holding. The rest follows from that:

  • One session per container. Identity comes from broker_host, so a container serving three platforms still has a single slot. Claiming it for PS2 takes it out of the GameCube pool as well.
  • A busy container is skipped quietly. The second player to press play lands on the second container and never learns the first one was taken.
  • The 409 waits until every member is held. That is RomM's way of saying no container is free, and it names the rom sitting on the container that would have been next.
  • The player already in a session gets that 409 too, rather than a second container. Falling back to another container in the pool is only for players who missed out on the first one. A second session for the same user would be one nothing could reach, since status, release and save-and-exit all resolve to the first, and under whole-card sync the two halves would be mounting the same card.
  • Saves follow the player, not the container. RomM keeps track of them by user, platform and emulator, and the broker clears the working slot before each launch, so landing on a different member next time changes nothing the player can see.

What same origin costs you

Browsers send a site's cookies to every path under that site's own origin. RomM's session cookie works the same way, so once the container lives at a path under RomM's origin, that cookie is sent to the container on every request for /streaming/* too. The container has no use for it and does not read it, but it does cross the boundary. On a LAN deployment where the container is already trusted enough to be handed the ROM library, this is not a meaningful change. Don't point this setup at a container or network you don't control.

On this page