Skip to main content
This page puts the Floe web client and the signaling server behind one reverse proxy, so the whole instance lives at one address such as https://floe.example.com. It is for anyone taking the Docker stack to production, and it is the recommended layout: one certificate, one DNS record, and share links that need no extra setting.

The short version

Send /socket.io/, /ws, /api/, and /health to the signaling server, except /api/config, and send everything else to the web client. Leave NEXT_PUBLIC_SOCKET_URL empty, set CLIENT_URL to your domain, and set TRUSTED_PROXY_COUNT to 1. Then check the result with the commands under Verify it works.

Before you start

  • A domain whose DNS record points at this machine.
  • Ports 80 and 443 open to the internet, for visitors and for the HTTPS certificate.
  • The Docker quickstart stack running on this machine.
  • Caddy or nginx installed on the same machine as Docker. The samples below reach the two containers at localhost:3000 and localhost:3001.
If your proxy itself runs in a container, such as Nginx Proxy Manager, SWAG, or a Caddy container, localhost inside it is the proxy’s own container, and every route answers 502. Point it at the host’s address instead, or join it to the Compose network and use the service names client:3000 and server:3001.

Route table

The client and the signaling server claim different paths, so they coexist on one origin with no conflicts. Route them like this. /api/config is the one exception to the /api/ rule, and it needs its own route in both configs below. Send it to the signaling server and it returns 404. With the empty socket URL this page recommends below, the browser still lands on its own origin, which is the right answer, so the mistake never shows. Any instance that sets a non-empty socket URL loses it silently. Verify it works shows how to check it.
The server answers on /ws exactly. A trailing slash or a path prefix does not match, and the server answers that upgrade 404 Not Found, with no log line. If you proxy from a subpath, strip the prefix so the server still sees exactly /ws. A query string is fine.
A server image of version 1.11.0 or earlier answers such an upgrade with nothing at all, and the connection closes about 1 second later.

Proxy configuration

Caddy handles HTTPS certificates and WebSocket upgrades automatically, so the whole config is a handful of lines.
Caddyfile

Floe configuration

Leave the socket URL empty. The browser then talks to whatever host it was loaded from, which behind this proxy is already the right answer.
.env
Terminal
Your own domain in share previews and the sitemap needs a client you build yourself, because the published image cannot carry it: see Container images. CLIENT_URL is matched exactly, including the scheme and with no trailing slash. https://floe.example.com and https://www.floe.example.com are different origins to the CORS check, so use whichever one your users actually visit. TRUSTED_PROXY_COUNT must equal the number of proxy hops in front of the signaling server. One reverse proxy means 1. A CDN in front of that proxy means 2. Setting it higher than your real hop count lets clients forge X-Forwarded-For and slip past the per-address rate limits. Leaving it at 0, which is what Compose passes when .env does not set it, makes every user count as the proxy’s own address. All of them then share one set of limits, and the room seal that keeps a third network out of a link stops working. With the count at 1, also keep ports 3000 and 3001 from being reached around the proxy: see TRUSTED_PROXY_COUNT.

Using the CLI and the desktop app with a one-domain instance

One address is all the CLI needs. The share link it prints resolves to the same origin, so it opens the web client correctly with no extra flag:
Terminal
The --web flag only exists for split deployments where the client lives at a different address from the signaling server. The desktop app takes the same single address. Open Settings and, under Advanced, expand Server. Put https://floe.example.com in Server address and click Test. The test runs three checks in order: that /health answers as a Floe signaling server, that the realtime /ws connection is accepted, and that GET /api/turn-credentials returns a usable connection list. A proxy that forwards only some of those is named in the failure message instead of breaking silently later. A server with no TURN relay still passes, with “Connected, but this server has no relay: Hide my IP will not work, and some networks will not connect”. Floe Desktop 0.3.0 says “Connected, but there’s no relay for Hide my IP” instead. That matters to Hide my IP address and to any two devices that cannot reach each other directly. Leave Share link address blank on a one-domain instance and the app derives the link from the server address. See Desktop app settings. The test deliberately does not follow redirects. If you enter an http:// address and the proxy redirects it to https:// on the same host, as the nginx sample above does with a 301, the test reports “Error: That address redirects to https://. Start it with https:// and test again”. Change the address to https:// and click Test again. Any other redirect on /health is reported as “Error: That address answered HTTP 301 (likely the web app, not the server)”, with the status the proxy sent, rather than passing. Floe Desktop 1.0.4 and earlier report that HTTP line for a redirect to https:// as well. On this layout the address both apps use is your CLIENT_URL, which the server always accepts, so the Host header does not affect them here. It matters on the two-hostname layout. Request links are off on a new server until its policy file turns them on. On this layout they need nothing else from Floe Desktop: the blank Share link address already points at the web client, which serves the page a sender opens. See Turn on request links.

TURN does not go through the proxy

If you run the optional coturn relay, its ports stay directly exposed. TURN is not HTTP, so a reverse proxy cannot carry it. See TURN relay for the port list and firewall rules.

Verify it works

Terminal
The last one should answer with JSON such as {"socketUrl":"","commit":null}. An HTML page that reads Cannot GET /api/config means the request reached the signaling server, so your proxy sends /api/config to the wrong place. Then open https://floe.example.com in a browser. The connection badge should read Ready. If it reads Offline, the /socket.io/ route is the first thing to check. The signaling server tells browsers to use HTTPS for its own hostname for one year (Strict-Transport-Security), so a browser that has opened your instance over HTTPS refuses plain HTTP for that hostname afterward. A server image of version 1.11.0 or earlier extends that to every subdomain under the hostname as well.

Prefer two subdomains?

Splitting the client and server across app.your-domain.com and api.your-domain.com is still fully supported. See Production deployment.

If something goes wrong

Last modified on November 4, 2026