# Deploying LobbyForge behind Cloudflare (lobbyforge.org) This guide covers running LobbyForge with **Cloudflare** in front — including the **15-year Origin CA certificate** (`.pem`) model used for the official deployment. > **Not everyone deploys like this.** The DEFAULT and simplest path is > Let's Encrypt via the installer (`bash install.sh`, option defaults). > Use this guide only if you intentionally put Cloudflare's proxy in > front of your instance. Both paths are first-class; pick one. ## TL;DR — official lobbyforge.org setup | Step | Action | |------|--------| | DNS | `lobbyforge.org` (+ `community`, `docs`) → server IP, **proxied** (orange cloud) | | DNS | TURN hostname (`turn.lobbyforge.org`) → server IP, **DNS only** (grey cloud) | | Cloudflare SSL/TLS | Mode **Full (strict)** | | Certificate | Origin CA certificate, 15 years, `.pem` pair (see below) | | Install | `bash install.sh` → skip Let's Encrypt → "provide your own certificate" | | Real IPs | `cp infra/nginx/cf-real-ip.conf.example infra/nginx/conf.d/cf-real-ip.conf` + restart nginx | | Firewall | 80/443 open as usual; **3478, 5349, 7881/tcp + 49160-49200/udp + 50000-60000/udp must stay open** — Cloudflare does NOT proxy these | ## Why the proxy matrix matters Cloudflare only proxies HTTP(S)-like traffic. LobbyForge's realtime paths must reach the origin directly: | Traffic | Path | Behind CF proxy? | |---------|------|------------------| | Web app | `https://lobbyforge.org` | ✅ proxied | | WebSocket gateway | `wss://lobbyforge.org/ws` | ✅ proxied (CF supports WebSockets on proxied hostnames by default) | | LiveKit signaling | `wss://lobbyforge.org/livekit` | ✅ proxied (WebSocket) | | WebRTC media (ICE) | UDP 50000-60000 → **server IP directly** | ➖ not proxied — ICE candidates carry the server IP, DNS is irrelevant | | TURN relay | `turn.lobbyforge.org` TCP 3478/5349 + UDP 49160-49200 | ❌ **must be DNS-only** — CF does not proxy TURN ports; a proxied TURN hostname resolves to CF edge IPs and TURN dies | Rule of thumb: **HTTP(S)/WS hostnames may be proxied; anything the TURN/ ICE layer resolves must be DNS-only.** ## The 15-year Origin CA certificate 1. Cloudflare dashboard → **SSL/TLS → Origin Server → Create Certificate**. 2. Accept the defaults (RSA or ECC, hostnames `*.lobbyforge.org` + `lobbyforge.org`, validity **15 years**). 3. Cloudflare shows you the certificate and the private key **once** — save both as PEM files, e.g. `origin-cert.pem` and `origin-key.pem` (keep the key OFF the repo; `infra/certbot/` is git-ignored). 4. Run the installer: ``` bash install.sh ... Proceed with certbot now? [Y/n]: n Provide your own certificate instead? [y/N]: y Certificate (fullchain) PEM path: /root/origin-cert.pem Private key PEM path: /root/origin-key.pem ``` The installer validates that the cert parses, the key parses, and the two **match** (public-key comparison), then installs them at the exact path nginx expects (`infra/certbot/conf/live//`). nginx needs no other change. 5. Cloudflare **SSL/TLS mode must be "Full (strict)"** — Origin CA certificates are only trusted by Cloudflare, never by browsers; browsers see Cloudflare's edge certificate. Never use "Flexible" (it downgrades origin traffic to plain HTTP). Notes: - The certbot sidecar keeps running but finds nothing to renew — its `No renewals were attempted` log lines are harmless noise. - To replace the certificate in 15 years (or earlier): overwrite the two PEM files at `infra/certbot/conf/live//` — nginx's in-container cert watcher detects the change and reloads gracefully within ~60 seconds. No restart required. - Origin CA certs cover your hostnames **only via Cloudflare**. If you later point DNS directly at the server (grey cloud), browsers will reject the certificate — switch to Let's Encrypt in that case. ## Restoring real visitor IPs (important behind the proxy) LobbyForge's nginx deliberately sets `X-Forwarded-For` to the immediate peer — unspoofable, but behind Cloudflare every visitor would share an edge IP, defeating IP-based rate limits and audit trails. Fix: ```sh cp infra/nginx/cf-real-ip.conf.example infra/nginx/conf.d/cf-real-ip.conf docker compose -f infra/docker/docker-compose.prod.yml --env-file .env.prod restart nginx ``` Only connections arriving FROM Cloudflare's published ranges are rewritten to `CF-Connecting-IP`; direct connections keep their real address. Refresh the ranges from when Cloudflare announces changes. ### Which `LOBBYFORGE_TRUSTED_PROXY` to use The app keys rate limits (sign-in included) on the client address, and it only believes the header named by `LOBBYFORGE_TRUSTED_PROXY`: - **Bundled nginx (this guide): keep `LOBBYFORGE_TRUSTED_PROXY=x-forwarded-for`** (the installer's default) and install `cf-real-ip.conf` as above. nginx then rewrites the address only for connections that really come from Cloudflare and passes the result on as `X-Forwarded-For`, so a visitor who reaches the origin directly still gets their own address. - **`LOBBYFORGE_TRUSTED_PROXY=cloudflare`** makes the app read `CF-Connecting-IP` itself. That is only safe when the origin accepts traffic **from Cloudflare alone** (firewall limited to Cloudflare's ranges, or a Cloudflare Tunnel). Otherwise anyone who finds the server's IP can connect directly and send any `CF-Connecting-IP` they like — a fresh rate-limit bucket on every request, or a victim's address to lock them out. nginx forwards that header untouched. - **Unset (or `none`) in production**: every visitor shares one rate-limit bucket, so one client can lock everyone out of sign-in. The app still starts (existing installs are not refused); it logs a warning once and Admin → Health (Doctor) shows a `trusted_proxy` warning with this fix. ## Caching: marketplace plugin files stay out of the CDN Cloudflare caches by file extension and ignores `Vary`. Marketplace plugin UI files (`/api/plugin-ui/{plugin}/{version}/…`, ADR-007) are served `Cache-Control: public, max-age=31536000, immutable` for browsers, but the route decides per request whether to serve them at all: it refuses (403) a plugin script requested by one of the app's own pages (`Sec-Fetch-Site: same-origin`), so that a plugin's JavaScript on your domain can never be used as a gadget against the app. A CDN that cached the frame's 200 would hand it to exactly that request. So every response of that route — files and refusals — also carries `CDN-Cache-Control: no-store` and `Cloudflare-CDN-Cache-Control: no-store`. Cloudflare honours these over `Cache-Control`, so it never stores them, while browsers keep their year-long cache. Nothing to configure; just do not add a Cache Rule that forces caching ("Cache Everything" with an Edge TTL) on `/api/plugin-ui/*` — that would override the headers. ## Without Cloudflare (the default path) No Cloudflare account needed: point DNS at the server, open ports 80/443, run `bash install.sh` and accept the certbot default. Let's Encrypt provisions and auto-renews via the certbot sidecar, and there is no proxy in the middle — but check the IPv6 case below: with Docker's defaults, visitors arriving over IPv6 can all show up as one address. This is the path most self-hosters should start with — you can move to Cloudflare later (re-run the installer with the own-certificate option). ## Client addresses behind Docker security-review INFRA-V-01. Docker publishes nginx's ports 80/443. For IPv4 it rewrites the destination in the kernel, so nginx sees each visitor's real address. For **IPv6**, when the `lobbyforge-edge` network has no IPv6 (the default), Docker's *userland proxy* (`docker-proxy`) accepts the connection on the host and opens a new IPv4 connection to nginx — from the bridge gateway, e.g. `172.18.0.1`. Every IPv6 visitor then has the same address. On some hosts (hairpin NAT, a proxy in front) every visitor does. Why it matters: LobbyForge keys sign-in rate limits, the guest limit and the daily image-upload budget on the client address. When everyone shares one address, one client can lock all of them out of sign-in, and the per-address budgets become instance-wide. **Detect it.** Admin → Health (Doctor) shows a `client_address` warning when the request asking for the report, or most recent requests, come from a gateway address (the container's default gateway, or a private `x.x.x.1`) while `LOBBYFORGE_TRUSTED_PROXY` is set. You can also look at nginx's log: `docker logs lobbyforge-nginx --tail 50` — every line starting with the same `172.x.0.1` is the problem. **Fix it** — one of these, on the Docker host: 1. **Give the edge network IPv6** (keeps IPv6 working, with real addresses). Docker Engine 27+ has `ip6tables` on by default. Add a unique-local subnet to the `edge` network in `infra/docker/docker-compose.prod.yml`: ```yaml networks: edge: name: lobbyforge-edge enable_ipv6: true ipam: config: - subnet: fd42:1f0b:c0de::/64 ``` then recreate the stack (`docker compose -f infra/docker/docker-compose.prod.yml --env-file .env.prod down && … up -d`; the network must be recreated, volumes are kept). Keep the edit when you update — it is a local change to a tracked file. 2. **Turn off the userland proxy.** In `/etc/docker/daemon.json`: ```json { "userland-proxy": false, "ip6tables": true } ``` and `sudo systemctl restart docker`. Docker then never proxies an IPv6 connection to an IPv4-only container: browsers fall back to IPv4 (Happy Eyeballs) and arrive with their real IPv4 address. Combine with option 1 to serve IPv6 properly. 3. **Publish no `AAAA` record** for the domain. Visitors connect over IPv4 only. Simplest, and fine for most communities. After the change, open Admin → Health again: `client_address` should say the addresses look like real visitors. `install.sh` does not change Docker's daemon settings for you. ## Troubleshooting | Symptom | Cause / fix | |---------|-------------| | Voice connects for some users, never for others | TURN hostname proxied — set it DNS-only; verify UDP ranges open on the host firewall | | `curl https://` works, app shows CF IPs in logs | `cf-real-ip.conf` not installed / nginx not restarted | | Everyone gets "Rate limit exceeded" at once; Doctor shows `trusted_proxy` | `LOBBYFORGE_TRUSTED_PROXY` unset — set it to `x-forwarded-for` in `.env.prod` and recreate `web` | | IPv6 visitors share one rate limit; Doctor shows `client_address`; nginx logs show `172.x.0.1` | Docker's userland proxy — see [Client addresses behind Docker](#client-addresses-behind-docker) | | Browser certificate warning | SSL/TLS mode is not Full (strict), or you grey-clouded a hostname while using an Origin cert | | WS connects then drops every ~100s | A proxy in between without WebSocket support — keep the hostname proxied only via Cloudflare, which supports WS | | certbot log spam | Expected with an origin certificate — nothing renews, ignore |