Reverse proxy
Serving the container from a subfolder of RomM's origin, with recipes for nginx, Caddy and Traefik and a note on Zoraxy.
The room UI iframes the selkies stream, and RomM in turn iframes the room. When that outer iframe is cross origin, the browser stops delivering pointer events to the parent document the moment the cursor enters it. RomM's player cannot see the mouse at all, so it has to overlay a strip of its own along the bottom edge to raise its control bar, and that strip sits on top of the container's own taskbar.
Serving the container from a subfolder of RomM's origin fixes that: the player attaches listeners inside the frame, nothing overlays the stream, and the container's desktop is fully clickable.
Nothing about this is proxied by RomM itself. A reverse proxy in front of both services maps a path on RomM's origin to the container, and the container is already built for it.
The contract
Three rules, all of them load bearing.
SUBFOLDER must equal the mount path exactly, trailing slash included.
The app emits absolute asset paths (/streaming/assets/...) and builds its
websocket URL from window.location.host plus that prefix. Set them to the
same value and the whole thing follows the mount with no rewriting anywhere.
The proxy must pass the prefix through, not strip it.
This is the one that bites. Most proxies offer a strip-prefix mode and several make it the default. Stripping produces a page that loads and then 404s every asset it asks for.
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. The container's own nginx serves the room UI, the broker API, and the selkies stream under that one prefix, so a single proxy rule covers all three. Port 3000 is HTTP, 3001 is HTTPS with a self signed certificate. Behind a proxy that terminates TLS already, target 3000 and save yourself the certificate exception.
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:3009
# The container's SUBFOLDER. RomM builds broker API paths under it.
subfolder: /streaming
broker_secret: "..."
protocol: webstation
emulator: pcsx2
library_path: /romm
label: WEBSTATIONhost also still accepts a full URL (https://webstation.example.com), which
is the cross origin deployment. Everything works there except the in frame
pointer tracking, and RomM's player falls back to its edge strip automatically.
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:3009;
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 component. Adding even a bare / makes
nginx strip /streaming/ before forwarding, which breaks every asset.
proxy_buffering off matters for the stream, and the long read timeout keeps an
idle session's websocket from being culled mid game.
Zoraxy: use a host rule, never a virtual directory
Zoraxy's only path based routing is the Virtual Directory, which is an Apache style alias rather than an nginx location: it strips the matched prefix and appends the remainder to whatever path the target names. Naming the prefix in the target as well (wiki) does put it back, and plain HTTP then works end to end:
| Match | Target | /streaming/api/health reaches the container as |
|---|---|---|
/streaming | 10.0.1.56:3009 | /api/health, which matches no location, so nginx serves its default "Welcome to nginx" root |
/streaming | 10.0.1.56:3009/streaming | /streaming/api/health, correct |
Websockets do not follow that rewrite. The upgrade path only strips, so with
the working target above, /streaming/ws/room still arrives as /ws/room and
nginx answers 404. Same for /streaming/stream/websocket. Reported as
tobychui/zoraxy#882 and closed
without a fix. Measure it rather than trusting the rule looks right, and force
HTTP/1.1 or the upgrade headers never leave curl:
curl -sk -i --http1.1 -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H "Sec-WebSocket-Key: $(head -c 16 /dev/urandom | base64)" \
https://romm.example.com/streaming/stream/websocket | head -1101 Switching Protocols is a working mount. A 404 from nginx means the
upgrade was forwarded without the rewrite, and there is no virtual directory
setting that repairs it. The stream, the collab room and the input channel are
all websockets, so the room loads and then immediately shows "Session Ended".
Only the virtual directory is affected. A plain Zoraxy host rule forwards websocket upgrades correctly, measured with the same handshake above. So Zoraxy stays in front: point the host rule at something that can do the mount (nginx or Caddy from the recipes above, on the same origin as the parent), and delete the virtual directory so the prefix reaches that proxy untouched. Giving the container its own hostname and running it cross origin also works, and costs the in frame pointer tracking and nothing else.
Verifying
curl -sS -o /dev/null -w '%{http_code}\n' https://romm.example.com/streaming/Expect 200. Keep the trailing slash: the container's nginx answers a bare
/streaming with a 301 to http://<host>:3000/streaming/, and that absolute
redirect carries the container's 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 per container. The app bakes its prefix into asset URLs at
startup, 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 with the matching host and
subfolder.
What same origin costs you
RomM's session cookie is scoped to its origin, so once the container lives at a
path under that origin the cookie is sent to the container on every request for
/streaming/*. 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. It is worth
knowing before pointing this at anything you do not control.