.env file next to docker-compose.yml. 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 rows that do say so.
Variables
| Variable | Service | Required | Default | Purpose |
|---|---|---|---|---|
NEXT_PUBLIC_SOCKET_URL | client (runtime) | No | http://localhost:3001 | 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. Docker Compose passes this into the client container as SOCKET_URL, which is the name the container itself reads, so outside Compose set SOCKET_URL directly. See how the client finds the server. |
FLOE_IMAGE_TAG | compose | No | latest | Which published image tag to run, for example 1.10.1, 1.10, main, or sha-fed7ebb. 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 | client (build) | No | (none) | Canonical public origin of your client (e.g. https://app.your-domain.com), used for canonical, Open Graph, JSON-LD, and sitemap URLs. Inlined at build time, so the published image cannot carry it and omits those tags instead of claiming floe.one. Set it only when building the client yourself. |
PORT | server | No | 3001 | 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. Change this and you must also update NEXT_PUBLIC_SOCKET_URL to match, then run docker compose up -d, or browsers keep dialing the old port. No rebuild is needed, because the client reads that address at runtime. |
CLIENT_PORT | client | No | 3000 | Host port the web client is published on. Inside the container it always listens on 3000. Compose-only; it has no effect outside Docker. |
NODE_ENV | server | No | development | Standard Node.js runtime flag. The Docker image sets this to production for you, so this default only applies when you run the server directly with Node. Set it to production if you do. Floe registers its own final error handler that returns a generic JSON error at every setting, so this is not a stack-trace leak, but production is what the published image runs and what Express’s own caching expects. |
CLIENT_URL | server | Yes (prod) | (none) | Frontend origin allowed by CORS. Matched exactly, including scheme 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. https://floe.one, https://www.floe.one, and http://localhost:3000 are always allowed in addition to whatever you set here. The value governs browser traffic only (HTTP CORS and Socket.IO). Requests with no Origin header are allowed too, which is why CLI and desktop peers reach your server regardless of this setting. |
TRUSTED_PROXY_COUNT | server | No | 1 | Number of trusted reverse-proxy hops in front of the server, used for correct X-Forwarded-For parsing and per-IP rate limiting. Use 1 behind a single reverse proxy, 2 behind a proxy plus a CDN, and 0 when the server is exposed directly with no proxy. Docker Compose passes 0 explicitly, because publishing container ports directly is its default. Setting it above your real hop count lets clients forge X-Forwarded-For and bypass the rate limits, so set it to 0 deliberately rather than leaving it unset on a directly exposed host. |
MAX_CONNECTIONS_PER_IP | server | No | 30 | Maximum new connections allowed per IP per 60-second window, shared across Socket.IO and WebSocket. Raise this in staging or test environments that drive many connections from a single IP. |
MAX_TURN_REQUESTS_PER_IP | server | No | 20 | Maximum GET /api/turn-credentials requests per IP per 60-second window. Requests over the cap get 429, and the client silently falls back to STUN-only. Raise this in CI or staging suites that start many transfers from one IP. |
MAX_CODE_REQUESTS_PER_IP | server | No | 60 | Maximum requests per IP per 60-second window against the room-code endpoints, shared across POST /api/code and GET /api/code/:code. Requests over the cap get 429. Raise this in CI or staging suites that mint many codes from one IP. |
MAX_ACTIVE_CODES | server | No | 10000 | Maximum number of simultaneously-live room codes. Once this cap is reached, POST /api/code returns 503 until existing codes expire (10-minute TTL) or are consumed. Bounds memory use of the code registry; raise it only if you legitimately expect more than ten thousand pending transfers at once. |
CLOUDFLARE_TURN_KEY_ID | server | No | (none) | Turn Token ID of a Cloudflare Realtime TURN key. With the API token below, the server mints short-lived TURN credentials from Cloudflare’s managed network, so you need no public IP, extra ports, or TLS certificates. Takes precedence over the coturn variables. |
CLOUDFLARE_TURN_KEY_API_TOKEN | server | No | (none) | API token that pairs with CLOUDFLARE_TURN_KEY_ID. Keep it secret. |
TURN_SECRET | server | No | (none) | Shared HMAC secret for self-hosted coturn credentials, used only when the Cloudflare variables are unset. 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 | server | No | (none) | Public hostname of your self-hosted TURN server (e.g. turn.your-domain.com). Must match coturn’s realm. |
UPSTASH_REDIS_REST_URL | server | No | (none) | 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. |
UPSTASH_REDIS_REST_TOKEN | server | No | (none) | REST token that pairs with UPSTASH_REDIS_REST_URL. Both must be set together for durable stats. Use the read+write token (the default one in the Upstash console), not the read-only token. The server needs write access to increment the counter; with a read-only token, writes are silently rejected and the total never persists. |
MAX_REPORT_BYTES | server | No | 5497558138880 (5 TB) | Maximum bytes accepted in a single POST /api/stats/report request. Rejects implausibly large single-transfer reports. |
Minimal local configuration
For local testing nothing needs setting. Every value has a working default, and the client already points athttp://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 Quick Start.
Production configuration
A typical production.env for a deployment with HTTPS and TURN:
.env
NEXT_PUBLIC_SOCKET_URL=https://api.your-domain.com
CLIENT_URL=https://app.your-domain.com
TRUSTED_PROXY_COUNT=1
# Managed TURN via Cloudflare (recommended)
CLOUDFLARE_TURN_KEY_ID=your-turn-token-id
CLOUDFLARE_TURN_KEY_API_TOKEN=your-api-token
# Or self-hosted coturn instead:
# TURN_SECRET=your-strong-random-secret
# TURN_DOMAIN=turn.your-domain.com