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: WEBSTATIONhost 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 fromhttps://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 matchsubfolder. - 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:3000A 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/healthRunning 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 abovesubfolder and the path in host must both equal the container's own SUBFOLDER.
They answer to the same value for different reasons:
subfolderis where RomM's backend finds the broker API (broker_host+subfolder+/api/...). The container only serves it under its ownSUBFOLDER, so a wrong value makes every claim fail with a 404 from the broker.- The path in
hostis where the browser starts, but the broker answers a claim with a room URL built from its ownSUBFOLDER. That is an absolute path, so it replaces whatever pathhostcarried.
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.
ROM cache
Keep local copies of recently played games, so a library on a slow NFS or SMB mount stops stuttering and loading slowly.
Unraid with an older NVIDIA GPU
Getting Vulkan working in the container on Unraid with a pre-595 NVIDIA driver (Pascal cards like the GTX 1050 Ti): host checks, the missing modeset device, and the template settings.