Skip to main content
Every environment variable you can set on a self-hosted Floe instance, with its default, and the per-address rate limits the signaling server applies. Under Docker Compose, set these in an optional .env file next to docker-compose.yml, then run docker compose up -d to apply the change. Each one has a working default, so you only need the file to change something. The annotated template is .env.docker.example. A few of them behave differently outside Compose, and the entries that do say so.

The short version

Set CLIENT_URL to the address people open your web app at, and NEXT_PUBLIC_SOCKET_URL to the address their browsers reach the signaling server at (empty behind one domain). Behind a reverse proxy, set TRUSTED_PROXY_COUNT to the number of proxies in front of the server. Everything else is optional and has a working default.

Variables

CLIENT_URL is the one you must set, as soon as anyone opens the web app at an address other than http://localhost:3000 (or the port you set in CLIENT_PORT). Every other variable is optional. Each name links to its entry under Variable reference. The Service column says who reads each one. server and client (runtime) take effect when the container is recreated with docker compose up -d. client (build) takes effect only when you build the client yourself. compose is read by Docker Compose itself. The client also reads NEXT_PUBLIC_SENTRY_DSN, SENTRY_DSN, SENTRY_ORG, SENTRY_PROJECT, and NEXT_PUBLIC_UMAMI_WEBSITE_ID, for error reporting and analytics. The published image is built with none of them set and Compose passes none, so a self-hosted instance sends no error reports and loads no analytics script. You cannot add them through .env. NEXT_PUBLIC_SENTRY_DSN and NEXT_PUBLIC_UMAMI_WEBSITE_ID are written into the client when it is built, and neither the published image nor the build overlay takes them. The way to add them is a client you build with Node from the repository, with both in client/.env.local before pnpm build (see Run without Docker). Only SENTRY_DSN is read while the client runs, and only for errors on the client’s own server side. See Security and privacy.

What to know about the limits

Each per-address rate limit counts one network address over the last 60 seconds, and a restart clears every count. There are five: connections, TURN requests, code requests, failed code lookups, and stats reports. A request that a limit refuses is not counted toward that limit, so a client that keeps retrying is let back in as its earlier requests age out. What counts as one address: an IPv4 address in full, or an IPv6 /64, so everyone inside one /64 shares each limit. An IPv4 client that a dual-stack listener reports as ::ffff:203.0.113.5 counts as plain 203.0.113.5. A port the proxy writes after the address, as in 203.0.113.5:4711, is dropped first. One more limit counts open connections, not new ones. MAX_SOCKETS_PER_IP caps how many connections one address holds open at once, and a connection gives its place back when it closes. One that drops without closing, such as from a laptop put to sleep, holds its place until the server’s heartbeat ends it, within about a minute. It counts an address the same way, and a restart clears it too. A MAX_* variable takes a whole number of at least 1. Given anything else, such as 0, a negative number, a fraction, a word, or a spelling such as 1e3, the server keeps the built-in default. It logs one line that names the variable, for example MAX_CONNECTIONS_PER_IP is not a whole number of at least 1. Using the default, 30. So neither 0 nor -1 disables a limit. An empty value means the default and logs nothing. To raise a limit, set a bigger number. A server image of version 1.11.0 or earlier logs nothing for such a value, and takes a negative number literally, which refuses everything that limit guards. TRUSTED_PROXY_COUNT is not one of them and genuinely accepts 0, which is what you want when the server is exposed directly with no proxy in front of it. The stats-report limiter has no environment variable. It is fixed at 60 reports per address per 60 seconds, and a report over it gets 429. The request-link limits have none either. They are constants in the server. See Turn on request links.

Variable reference

NEXT_PUBLIC_SOCKET_URL

URL the browser uses to reach the signaling server. Read at runtime, so changing it needs only docker compose up -d, no rebuild. Set it empty when both services share one origin behind a reverse proxy; the browser then uses its own. Empty means the line NEXT_PUBLIC_SOCKET_URL= with nothing after it: remove the line and the default comes back. The client container itself reads the name SOCKET_URL, which has no default of its own: unset, the browser uses the page’s origin. Which name you set depends on how you run the client: See How the client finds the server.

FLOE_IMAGE_TAG

Which published image tag both containers run, for example 1.12.0, 1.12, main, or sha- followed by the first 7 characters of a commit. See Container images. Not to be confused with FLOE_VERSION, which belongs to the CLI installer and carries a leading v.

NEXT_PUBLIC_SITE_URL

Public origin of your client (for example https://app.your-domain.com), written into canonical links, share previews, the JSON-LD block, and the sitemap. Inlined at build time, so a pulled image ignores it: the published image leaves its own address out of those instead of claiming floe.one, as Container images lists. It takes effect only when you build the client yourself. A build that does not go through the official Dockerfile and leaves it unset falls back to https://www.floe.one.

SOURCE_COMMIT

Source revision the client reports as commit from GET /api/config, so anyone can check which build an instance is running. Read at request time from the client container’s environment; the field is null when it is unset. Compose does not pass it through, so add it to the client service’s environment yourself if you want it served. On Vercel the field comes from VERCEL_GIT_COMMIT_SHA instead, which is how floe.one gets its value. See the HTTP API reference.

REQUEST_REPORT_EMAIL

Address that Report this link, on the page a sender opens from a request link, starts an email to. Unset or empty, that page shows no Report this link at all. Set it when you turn on request links: a report is about a link on your server, and only you can act on it. It takes exactly one address in a plain shape. That is letters, digits and . _ + - before the @, then a hostname with at least one dot, at most 254 characters in all. Anything else counts as unset, including two addresses, a name in front of the address, and a space before or after it. Read at request time from the client container’s environment, so changing it needs only docker compose up -d, no rebuild. Compose passes it through from .env, which takes a current docker-compose.yml: a copy downloaded before this variable existed leaves it out without a word. On Unraid it is the Floe-Client template’s advanced Request Link Report Email field. It needs a web client newer than version 1.11.0: see What an older web app shows. floe.one uses a built-in address and ignores this variable. What the sender sees is in Report a link.

PORT

Host port the signaling server is published on. Inside the container it always listens on 3001; Docker Compose maps ${PORT} to it. Outside Docker this is the port the server process binds directly. Under Compose the address browsers are given follows it: while NEXT_PUBLIC_SOCKET_URL is unset, it is http://localhost: and this port. A NEXT_PUBLIC_SOCKET_URL you set yourself has to carry the new port, or browsers keep dialing the old one. Run docker compose up -d to apply. No rebuild is needed, because the client reads that address at runtime.

CLIENT_PORT

Host port the web client is published on. Inside the container it always listens on 3000. While CLIENT_URL is unset, Compose passes the server http://localhost: and this port, so on one machine changing it is all you need. Run docker compose up -d to apply. A CLIENT_URL you set yourself has to carry the new port. It names the origin the server accepts your web client from, so a page served from a port it does not name loads and never connects. Compose-only; it has no effect outside Docker.

NODE_ENV

Standard Node.js runtime flag. The server image sets it to production, and Compose passes no value from .env, so under Docker there is nothing to set. On a direct Node run it is unset unless you set it, which Express reads as development; server/.env.example sets production, so keep that line. Floe never reads it, and registers its own final error handler that returns a generic JSON error at every setting, so this is not a stack-trace leak. Keep production anyway: it is what the published image runs and therefore what is actually exercised.

CLIENT_URL

The web app’s origin, which the server lets in through its CORS check and its WebSocket origin check. Matched exactly, including the scheme and the port, and with no trailing slash. Use https://floe.example.com for a one-domain deployment, or https://app.your-domain.com when the client has its own subdomain. Under Compose it defaults to http://localhost: and the value of CLIENT_PORT. A value you set has to carry the port itself. Holds one origin: a comma-separated list matches nothing at all. It governs browser traffic only: the CLI and Floe Desktop reach your server regardless of it, as long as any proxy in front passes the Host header through. That rule, and the origins the server always accepts, are in Origins the server accepts.

TRUSTED_PROXY_COUNT

The number of reverse proxies in front of the server. The server reads each visitor’s address from the X-Forwarded-For header through that many proxies, and every per-address limit counts that address. What an empty or missing value means depends on how you run the server. Docker Compose and the Unraid template pass 0 when you set nothing, because publishing container ports directly is their default, so raise it when you put a proxy in front. A server started with Node directly uses 1 when the variable is unset or empty. It then logs one line at startup: TRUSTED_PROXY_COUNT is not set, so the default of 1 is in use. Set it to the number of reverse proxies in front of this server, or 0 if there are none. server/.env.example sets 0, so a server started with that file logs nothing. A value that does not start with a number, such as a word, counts as 0, and so does a negative number; the server then logs one line at startup: TRUSTED_PROXY_COUNT is not a whole number, so the default of 0 is in use. Set it to the number of reverse proxies in front of this server, or 0 if there are none. The line never repeats the value. A value that starts with a number is read up to the first character that is not a digit, and logs nothing: 1.5 counts as 1. Set it too high, and clients can write their own X-Forwarded-For and pick the address every limit counts them under. Set it too low behind a proxy, and every visitor arrives as the proxy’s address and shares one set of limits. The same address decides the room seal, which turns away a device on a third network once two devices have connected through a link. With the count too low, every peer looks like the proxy, so the seal never engages for anyone. With it too high, the address is whatever the visitor wrote. How the seal works for the people using a link is in How a transfer works.
Behind a proxy, keep ports 3000 and 3001 from being reached directly. A visitor who calls the server’s port directly can send an X-Forwarded-For header of their own, and with a count of 1 the server believes it. That lets them step past every limit, the code-guessing budget included. Block the two ports at your firewall, or publish them on the local machine only by changing the two ports lines in docker-compose.yml to "127.0.0.1:${PORT:-3001}:3001" and "127.0.0.1:${CLIENT_PORT:-3000}:3000".

MAX_CONNECTIONS_PER_IP

Maximum new connections admitted per address per 60 seconds, counted across Socket.IO and WebSocket together. A connection over the cap is refused with Rate limit exceeded, and a refused attempt is not counted. Raise this in staging or test environments that drive many connections from one address.

MAX_SOCKETS_PER_IP

Maximum connections one address holds open at once, counted across Socket.IO and WebSocket together. A browser tab on a transfer page holds one, as does floe send or floe receive while it runs, and each Floe Desktop transfer or open request link. A connection over the cap is refused with Rate limit exceeded, the same refusal MAX_CONNECTIONS_PER_IP gives, so the web app shows the same banner. A refused attempt is counted against neither limit, and the next attempt gets in once another connection from that address has closed. Raise this when many people share one address, such as an office behind one router. A server image of version 1.11.1 or earlier has no such cap.

MAX_TURN_REQUESTS_PER_IP

Maximum GET /api/turn-credentials requests per address per 60 seconds. Requests over the cap get 429, and the client falls back to STUN only: silently in the browser and Floe Desktop, with a printed warning on the CLI. A transfer that insists on the relay stops instead: Floe Desktop with Hide my IP address on, a request link’s page with Hide my IP on, or --relay-only on the CLI. Raise this in CI or staging suites that start many transfers from one address.

MAX_CODE_REQUESTS_PER_IP

Maximum requests per address per 60 seconds against the room code endpoints, counted across POST /api/code and GET /api/code/:code together. Requests over the cap get 429. Raise this in CI or staging suites that mint many codes from one address.

MAX_FAILED_CODE_RESOLVES

Maximum failed lookups on GET /api/code/:code per address per 60 seconds. This is the guessing budget; MAX_CODE_REQUESTS_PER_IP above is the volume ceiling, and both apply. Only misses count: a lookup that finds a live code costs nothing, so a receiver holding a real code is never turned away by its own retries. The budget is checked before the lookup. An address that has used it up therefore gets the same 429 whatever it asks about, a working code included, until its misses are more than 60 seconds old. Raise this only if a shared address legitimately mistypes codes more than 10 times a minute.

MAX_ACTIVE_CODES

Maximum number of simultaneously live room codes, across all addresses. Once this cap is reached, POST /api/code returns 503 until slots free up. A code gives its slot back as soon as it is retired, which in normal use is long before its 10-minute limit; see How long a code and a link last. A sweeper clears whatever is left over once a minute, so the count can briefly include codes that have already expired. Bounds memory use of the code registry; raise it only if you legitimately expect more than 10,000 pending transfers at once.

POLICY_FILE

Absolute path to the request-link policy file, a small JSON file such as {"requestLinks": true} that turns request links on. Unset, or naming a file that does not exist, means request links are off. When the path is set and no file is there, the server also logs policy file not found, request links off once, which is how a volume that was not mounted shows. The path is read once at startup and the file is re-read every 60 seconds, so turning request links on or off needs no restart. The image never contains the file: under Compose, uncomment the volumes example in docker-compose.yml, which mounts ./policy at /app/policy, and set this to the path inside the container, such as /app/policy/policy.json. The server runs as the user node, which has to be able to read the folder and the file. See Turn on request links.

CLOUDFLARE_TURN_KEY_ID

Turn Token ID of a Cloudflare Realtime TURN key. With the API token below, the server mints TURN credentials, each valid for 24 hours, from Cloudflare’s managed network, so you need no public IP, extra ports, or TLS certificates. Takes precedence over the coturn variables. Both Cloudflare variables are read once at startup, and setting only one of them leaves Cloudflare unused, with one line in the log saying so. A server image of version 1.11.7 or earlier logs nothing.

CLOUDFLARE_TURN_KEY_API_TOKEN

API token that pairs with CLOUDFLARE_TURN_KEY_ID. Keep it secret.

TURN_SECRET

Shared HMAC secret for self-hosted coturn credentials. Used when the Cloudflare variables are unset, and as the fallback when Cloudflare returns nothing usable. Needs TURN_DOMAIN as well: with only one of the two set, coturn is skipped, with one line in the log saying so. A server image of version 1.11.7 or earlier logs nothing. If no TURN option is configured, the server returns STUN-only and no relay is offered. Must match coturn’s static-auth-secret.

TURN_DOMAIN

Public hostname of your self-hosted TURN server (for example turn.your-domain.com). Must match coturn’s realm. Give the hostname alone: with a scheme, a port or a path, the server turns the coturn relay off, logs one line saying so and, without Cloudflare TURN, hands out the default STUN servers. A server image of version 1.11.7 or earlier hands those addresses out and logs nothing, and a client that refuses them starts no transfer: a browser refuses all three, and floe and Floe Desktop refuse a scheme or a port.

TURN_TLS

Set it to off when your self-hosted coturn has no TLS certificate. The server then leaves the turns: URL (TLS on port 5349) out of the relay list it gives clients, so they stop trying a relay that cannot answer. false, 0 and no are read as off too, in any letter case. Unset, empty or any other value keeps the URL, which a coturn with a certificate needs: on a network that blocks UDP, turns: is the relay. It does not change the Cloudflare list, and a server image of 1.11.0 or earlier ignores it. Under Compose it also needs a current docker-compose.yml: a copy from before 1.11.1 does not pass it to the server, so setting it in .env does nothing.

UPSTASH_REDIS_REST_URL

REST URL of an Upstash Redis database, used to durably persist the global transfer counter shown on the homepage. If empty, the counter runs in memory only and resets to 0 on every restart. The total is kept under the key floe:bytes_total, which is where to read it or reset it. Two instances given the same database share one total.

UPSTASH_REDIS_REST_TOKEN

REST token that pairs with UPSTASH_REDIS_REST_URL. Both must be set together for durable stats: with only one of them set, the counter stays in memory and the server logs one line saying so. Use the Standard token, the one the Upstash console shows by default, not the Read Only token. The server needs write access to increment the counter; with a read-only token, writes are refused and the total never persists. The server logs one Upstash counter update failed (...) line when that happens; see Operations. A server image of version 1.11.7 or earlier logs nothing in either case.

MAX_REPORT_BYTES

Maximum bytes accepted in a single POST /api/stats/report request. A larger report is refused with 400, which keeps implausibly large single-transfer reports out of the counter.

Minimal local configuration

For local testing nothing needs setting. Every value has a working default, and the client already points at http://localhost:3001 with no .env file at all. The first case that genuinely needs one is reaching the app from another device on your network, which takes NEXT_PUBLIC_SOCKET_URL and CLIENT_URL together. See Docker quickstart.

Production configuration

A typical production .env for a deployment with HTTPS and TURN, on two subdomains:
.env
See Production deployment for the full setup, or Run behind one domain for the one-domain layout, where NEXT_PUBLIC_SOCKET_URL stays empty.
Last modified on November 5, 2026