All pages
On this page

Running the relay behind a reverse proxy

A reverse proxy is a web server of your own that takes the connection from the browser and hands it to the relay. You need one when you want HTTPS for a relay on your LAN or on a server, when the relay should live under a sub-path of a site you already run (https://example.com/oar/), or when a tunnel's edge (for example Cloudflare) sits between the browser and the relay. A proxy on the relay's own machine works without any setting. A proxy on another machine needs two or three, shown below. Five config.json keys tell the relay about the proxy, and each one fixes something specific; they are explained after that.

What the proxy must do #

The relay's page, its API and its live connection all use one port (3333 unless you changed port). The proxy must:

  • Pass WebSocket upgrades. The app keeps a Socket.IO connection open to the relay, on the path /socket.io/ (with a prefix, <prefix>/socket.io/). If upgrades do not get through, the page loads but never shows live updates.
  • Not buffer or time out long-lived connections. That socket stays open for as long as the page is open, and turns stream to it as they happen. Use long read and idle timeouts, and do not buffer responses.
  • Pass the Host header, or send X-Forwarded-Host. The relay builds the address of a shared link from it, and Added in 0.9.11 accepts the live connection only from a page on that address (see allowedOrigins). X-Forwarded-Host and X-Forwarded-Proto count only from a proxy the relay trusts.
  • Send X-Forwarded-Proto when the proxy ends TLS, so the relay knows the browser used HTTPS (see Cookies and HTTPS), and X-Forwarded-For, so it knows the client's address.
  • Keep the path as it is. Do not rewrite it. If you serve the relay under a prefix, the relay removes the prefix itself (see remotePath).

You do not need a cache rule. The relay sends Cache-Control: no-store for its page, its service worker, its manifest and its API, and also CDN-Cache-Control: no-store and Cloudflare-CDN-Cache-Control: no-store. Do not add a rule that caches /api/shared/*: shared views poll for new content, and a cached answer would show viewers an old snapshot.

A minimal Caddy example for a relay on the same machine. Caddy passes WebSocket upgrades on its own and adds X-Forwarded-Proto:

relay.example.com {
    reverse_proxy 127.0.0.1:3333
}

For nginx or any other proxy, check its own documentation for how to pass upgrades and set timeouts, against the list above. For tunnels, see SSH tunnel and Cloudflare Tunnel; for the access token, see Access.

A proxy on another machine Added in 0.9.11 #

The built-in tunnels and a proxy on the relay's own machine reach the relay over 127.0.0.1, and the default trustProxy believes their forwarded headers. A proxy on another host, or in another container or VM, is a different peer. Until you trust it, the relay ignores its X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto, so:

  • every visitor has the proxy's address: one sign-in lockout for all of them, and one address in every access log entry;
  • new share links start with http:// and the host the proxy sent;
  • the sign-in cookie is not marked Secure.

Set three keys in the config file and restart the relay:

{
  "localhostOnly": false,
  "trustProxy": "192.0.2.10",
  "allowedOrigins": ["https://relay.example.com"]
}
  • localhostOnly: false lets the relay listen beyond loopback. Keep the port closed to everything but the proxy.
  • trustProxy is the proxy's address, its subnet, or the number of proxies in front of the relay (1 for one). See below.
  • allowedOrigins is needed only when the proxy rewrites the Host header and sends no X-Forwarded-Host. See below.

A proxy on the relay's own machine needs neither trustProxy nor localhostOnly: false, and allowedOrigins only if it rewrites Host. Under a URL prefix, set remotePath too.

The keys that tell the relay about the proxy #

All five are in the table on config.json. Restart the relay after changing them.

trustProxy #

Default "loopback". It decides whose X-Forwarded-* headers the relay believes. With the default, only a proxy that connects from the same machine is believed, which is right for Caddy, nginx or cloudflared running next to the relay.

What the relay takes from the headers of a trusted proxy:

  • whether the request came over HTTPS, which decides if the sign-in cookie carries the Secure flag, and the scheme of a new share link;
  • the host the browser addressed (X-Forwarded-Host), for new share links and for the origin check of the live connection;
  • the client's address, for the sign-in lockout, the presence limit of shared views and the access log of shared links. The country in that log (cf-ipcountry) is also read only from a trusted proxy.

If your proxy runs on another machine, the relay sees a non-local peer and ignores those headers. Give trustProxy what Express's trust proxy takes: a hop count (1), an address or subnet ("10.0.0.0/8"), a list of them, or "loopback".

Only with a proxy in front

Set trustProxy wider than "loopback" only when every connection to the relay really comes through your proxy. Otherwise anyone who can reach the port can send a forged X-Forwarded-Proto.

Added in 0.9.11 Every use of a forwarded header follows trustProxy. In 0.9.10 the presence limit of shared views and the address of a new share link read them from any client.

allowedOrigins Added in 0.9.11 #

Default []. The live connection (Socket.IO) accepts a browser's handshake only from the relay's own origin: the Origin the browser sends must have the host the browser addressed, which is X-Forwarded-Host when a trusted proxy sends one and Host otherwise. A page on another site cannot use your sign-in cookie to open a live connection. Clients that send no Origin, such as scripts, are not affected.

A proxy that rewrites the Host header to the relay's internal address and sends no X-Forwarded-Host breaks that comparison. List the public origin then, exactly as the browser shows it, with scheme, host and any port other than the default:

"allowedOrigins": ["https://relay.example.com"]

Without it the page loads and you can sign in, but nothing updates live, and the relay logs once per origin:

[socket] Refused a handshake from origin https://relay.example.com (host 127.0.0.1:3333); list it in allowedOrigins if a proxy rewrites the Host header

remotePath #

Default "". Set it to the prefix when the proxy serves the relay under a sub-path, for example "/oar". A leading slash is added and trailing slashes are removed.

With it set, the relay:

  • takes the prefix off every incoming request, so the proxy passes the path with the prefix and does not strip it;
  • puts the prefix into the sign-in cookie's path, the socket path (/oar/socket.io/), and the addresses of files and shared links.

Without it, a relay under a prefix cannot find its own files and its socket, because the page asks for /socket.io/ and /api/... at the root of the site. Open the relay with a trailing slash (https://example.com/oar/).

An installed app belongs to the address it was installed from, so a relay under a prefix is its own app. See Install as an app.

publicHostnames #

Default []. List the host names that reach the relay itself, for example ["relay.example.com"]. The relay uses them in one place: the preview lane refuses a previews.publicBaseUrl on one of these names (or on localhost or 127.0.0.1), so a previewed app never shares a login cookie with the relay. If you put the relay behind a proxy on a new name and use previews, add that name here.

tunnelMarkerHeaders #

Default []. The names of headers that your proxy or tunnel's edge adds to every request it forwards. The relay uses them for the session-worker path guard below. cf-ray, which Cloudflare adds, is always on the list. The value can be an array or a comma-separated string. The environment variable COPILOT_TUNNEL_MARKER_HEADERS (comma separated) adds more names; Added in 0.9.11 the two lists are merged, so tunnelMarkerHeaders: [] no longer switches the variable off. See Environment variables.

The status dot

tunnelMarkerHeaders does not turn the status dot amber. The dot is amber while the relay's own managed Cloudflare tunnel is connected (see the conversation menu). A proxy you run yourself never turns it amber.

The session-worker path guard #

Each agent session runs in a worker on the machine of the relay, and the worker talks to the relay over a WebSocket on /api/session-worker/ws (with a prefix, <prefix>/api/session-worker/ws). A proxy or tunnel forwards every path of its host name to the relay, so without a guard that path would be reachable from the internet. Only local workers should use it.

Added in 0.9.11 The guard lets a request to that path through only when it comes from the relay's own machine and nothing forwarded it:

  • the connecting peer is loopback (127.0.0.1 or ::1);
  • it carries no marker header: cf-ray, or one in tunnelMarkerHeaders;
  • it carries no X-Forwarded-For, Forwarded or X-Real-IP.

Anything else is refused before sign-in is checked:

  • a plain request gets 403 with {"error":"Session worker endpoints are reachable from this machine only"};
  • a WebSocket upgrade gets 403 Forbidden and the connection is closed.

Each refusal is logged as [tunnel-guard] Blocked session-worker path …: not local (…), naming what gave the request away. Local workers connect to 127.0.0.1 directly, without any of these headers, and are not affected. Shared links keep working through the tunnel: the guard keys on the worker path only, not on a request being anonymous.

Cloudflare adds cf-ray and Caddy adds X-Forwarded-For on its own, so the guard works with them without a setting; with nginx, send X-Forwarded-For with proxy_set_header. A forwarder that adds nothing at all, such as an SSH reverse tunnel or a bare TCP proxy, looks like a local process; the path is then protected only by the access token. With such a forwarder, make it add a header with a fixed non-empty value, for example X-Oar-Edge: 1, and list its name: "tunnelMarkerHeaders": ["x-oar-edge"]. Or refuse the path in front of the relay.

Endpoints only the relay's machine may use #

Added in 0.9.11 Two things answer only requests from the relay's own machine that no proxy forwarded, by the same rule as the path guard:

  • the session-worker socket, above;
  • running an image operation (POST /api/image-operations/…/execute), which the relay's own workers call. Anything else gets 403 Image operation execution is reachable from this machine only.

Everything else needs only the access token, from wherever it comes. That includes 🌄 Restart web relay, withdrawing a queued restart (the Cancel button on its banner) and 🚮 Empty queue: any signed-in browser can use them, also over the LAN and through any proxy or tunnel.

Up to 0.9.10 these three were meant for the relay's machine only, but the check let everything through a tunnel or a proxy on the same machine, and refused a direct LAN connection while the page reported success. If remote users must not restart the relay or empty its queue, have the proxy refuse POST /api/relay/shutdown, POST /api/relay/shutdown/cancel and POST /api/queue/empty. oar restart on the relay machine always works.

Cookies and HTTPS #

The sign-in cookie is HttpOnly and SameSite=Lax, lasts 30 days, and has the path / (or the remotePath prefix). It carries Secure when the relay considers the request secure.

  • Proxy ends TLS on the same machine. The proxy sends X-Forwarded-Proto: https, the default trustProxy believes it, and the cookie is Secure.
  • Proxy ends TLS on another machine. Set trustProxy as above. Until you do, the cookie has no Secure flag, though it still works.
  • Mixed setups. If you also open the relay directly over plain http on the LAN, that request is not secure, so its cookie has no Secure flag; the proxied HTTPS requests get the flag. Browsers keep cookies per address.

Check the setup #

  1. Open the proxied address in a browser. The sign-in or the conversation list appears.
  2. Look at the status dot: it turns green and the page shows live updates when you start a turn. An endless reconnecting state means the WebSocket does not get through.
  3. Under a prefix, open the address with the trailing slash. The page, its styles and the live connection load, and the cookie's path is the prefix (look in the browser's developer tools).
  4. Create a shared link (Sharing). Its address uses the public host name and https, and the prefix if you set one.
  5. Test the guard from outside: request https://relay.example.com/api/session-worker/ws. You should see 403 and a [tunnel-guard] line in the relay's log. If you get another answer, your proxy adds neither a marker header nor X-Forwarded-For.
  6. Behind a proxy on another machine: sign in from two devices, and check in Settings → Sharing that a shared link's access log shows the viewers' addresses, not the proxy's. If it shows the proxy's, trustProxy does not match the proxy.

When it does not work #

What you see Likely cause
The page loads but reconnects forever, or never updates The proxy does not pass WebSocket upgrades on /socket.io/, or closes idle connections.
The page loads but nothing updates live, and the log says Refused a handshake from origin The proxy rewrites the Host header. List the public origin in allowedOrigins, or have the proxy pass Host on.
Sign-in says Too many failed sign-in attempts for everyone at once The relay does not trust a proxy on another machine, so all visitors share its address and its lockout. Set trustProxy.
Under a prefix, the page is blank or files are missing remotePath is not set, or the proxy strips the prefix. The proxy must pass it.
Under a prefix, you are asked to sign in again and again The cookie's path is the remotePath prefix, so the browser sends it only under that prefix. Open the relay under that prefix, and with the trailing slash.
Shared links or file links have the wrong address or no prefix remotePath is unset, or the proxy sends no Host or X-Forwarded-Host / X-Forwarded-Proto, or the relay does not trust the proxy.
Share links start with http://, or the cookie has no Secure flag, behind a proxy on another machine trustProxy is still "loopback".
The worker path answers without a 403 through your proxy The proxy adds neither a marker header nor X-Forwarded-For. See the path guard.
The dot never turns amber Expected: only the relay's own managed Cloudflare tunnel does that.
Previews refuse to start on the new name Add the relay's host name to publicHostnames.

For the first sign-in and the access token, see First run.