Behind your own reverse proxy
FiveCord runs behind any proxy that terminates TLS and forwards WebSocket upgrades.
The stack ships Caddy and runs it by default on 80 and 443. One extra Compose file stops that and publishes a single plain HTTP port instead. That port already routes every path to the right internal service, so the proxy in front needs one routing rule: send everything to it.
Enable the overlay
Section titled “Enable the overlay”docker-compose.proxy.yml ships next to docker-compose.yml and layers on top of it. Pass both files on every command:
docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -dOr set COMPOSE_FILE in .env once and keep typing plain docker compose commands:
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.ymlOn a first install, sh install.sh --tls proxy writes that line and FLUXER_EDGE_BIND into .env for you, so there is nothing to add by hand. Installer flags lists it with the rest.
The port binds 127.0.0.1:8080 by default. FLUXER_EDGE_BIND sets both the interface and the host port. Move the port when something else on the host already holds 8080:
FLUXER_EDGE_BIND=127.0.0.1:8081The container side stays 8080 whatever you set. A proxy on the host then points at 127.0.0.1:8081, and a proxy on the stack’s own Docker network points at edge:8080. Bind a routable address only when the proxy runs on another machine, and firewall the port to that machine:
FLUXER_EDGE_BIND=0.0.0.0:8080Confirm the port answers before you configure anything in front of it:
curl -i http://127.0.0.1:8080/_health/_health returns 200 OK from the edge itself without reaching an upstream, so it is the health check to give your proxy.
What every proxy must do
Section titled “What every proxy must do”Terminate TLS and serve the hostname over HTTPS
Section titled “Terminate TLS and serve the hostname over HTTPS”With FLUXER_PUBLIC_SCHEME=https the admin CSRF cookie is __Host-csrf_token, which browsers accept only over HTTPS.
Forward WebSocket upgrades
Section titled “Forward WebSocket upgrades”The client connection is on /gateway and voice signalling is on /livekit/*. Both open as WebSocket upgrades. Check that the handshake completes through whatever is in front of the instance, with your own hostname in place of chat.example.com:
curl -sS -i --http1.1 --max-time 5 \ -H 'Connection: Upgrade' \ -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' \ -H 'Sec-WebSocket-Key: AAAAAAAAAAAAAAAAAAAAAA==' \ 'https://chat.example.com/gateway?v=1&encoding=json' | head -1The answer is HTTP/1.1 101 Switching Protocols. Confirm a real client connects as well, which covers the whole path including the query string.
Replace X-Forwarded-For with the real client address
Section titled “Replace X-Forwarded-For with the real client address”Set the header from the connection address and discard whatever the visitor sent. Rate limits, IP bans and abuse detection all apply to the address that arrives in it.
Accept a request body above the attachment limit
Section titled “Accept a request body above the attachment limit”Uploads pass through the proxy. Raise any default body limit, such as nginx’s 1 MB, above the instance attachment limit.
Hold a connection open for an hour
Section titled “Hold a connection open for an hour”The Gateway socket stays open for the life of a client session, so give idle and read timeouts an hour.
Pass Sec-Fetch-Site through untouched
Section titled “Pass Sec-Fetch-Site through untouched”Browsers send it, and the admin dashboard refuses a mutating request with a cross-site value.
Send no Content-Security-Policy of its own
Section titled “Send no Content-Security-Policy of its own”The instance sends its own policy, and its nonce is what lets the web app boot. Browsers enforce every policy they receive.
Every worked configuration below meets all of these requirements.
The public address
Section titled “The public address”The variables below tell the instance the address browsers use. FiveCord reads neither X-Forwarded-Proto nor X-Forwarded-Host, so you keep these correct by hand:
FLUXER_DOMAIN=chat.example.comFLUXER_PUBLIC_SCHEME=httpsFLUXER_PUBLIC_PORT=443They stay https on 443 even though the instance itself speaks plain HTTP on 8080. Clients read every base URL from the discovery document the API builds out of these values.
Serving on a port other than 443 means changing one of those variables:
FLUXER_PUBLIC_PORT=8443Your proxy holds the public port, and the overlay pins the edge to plain HTTP on 8080 whatever that port is, so nothing else in .env moves with it.
Leave FLUXER_PUBLIC_ORIGIN commented out, or keep it consistent with all three settings. Conflicting addresses can break sign-in and setup. See The public origin.
The map block makes Connection follow Upgrade. The snippet raises the body limit and the read timeout above nginx’s own 1 MB and 60 seconds.
map $http_upgrade $connection_upgrade { default upgrade; '' close;}
server { listen 80; listen [::]:80; server_name chat.example.com; return 301 https://$host$request_uri;}
server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name chat.example.com;
ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
client_max_body_size 512m;
location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header X-Forwarded-For $remote_addr;
proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_request_buffering off; proxy_buffering off; }}Set X-Forwarded-For from $remote_addr. $proxy_add_x_forwarded_for appends whatever the client sent, which puts an attacker-chosen address in front of the real one.
An external Caddy needs one site block. It forwards WebSocket upgrades natively, requests the certificate itself, and applies no request body limit and no read timeout of its own. By default it ignores the inbound X-Forwarded-* headers from untrusted clients and writes its own, so there is no header line to add.
chat.example.com { reverse_proxy 127.0.0.1:8080}Caddy trusts no proxy by default. If another proxy or CDN sits in front of it, configure Caddy’s global trusted_proxies option and FiveCord’s trusted proxies so requests retain the real client address.
Traefik
Section titled “Traefik”Traefik forwards WebSocket upgrades natively, and by default it strips inbound X-Forwarded-* headers from untrusted clients and writes its own. Leave forwardedHeaders alone unless another proxy sits in front of Traefik.
File provider
Section titled “File provider”Static configuration:
entryPoints: web: address: ":80" http: redirections: entryPoint: to: websecure scheme: https websecure: address: ":443" transport: respondingTimeouts: readTimeout: 0s idleTimeout: 3600s
providers: file: filename: /etc/traefik/dynamic.yml
certificatesResolvers: letsencrypt: acme: email: admin@example.com storage: /etc/traefik/acme.json httpChallenge: entryPoint: webreadTimeout defaults to 60 seconds and covers reading the whole request including the body, so 0s removes the bound and lets a large upload finish. idleTimeout defaults to 180 seconds, so raise it to an hour to hold the Gateway socket open.
Dynamic configuration in /etc/traefik/dynamic.yml:
http: routers: fluxer: rule: Host(`chat.example.com`) entryPoints: - websecure service: fluxer tls: certResolver: letsencrypt
services: fluxer: loadBalancer: passHostHeader: true servers: - url: http://127.0.0.1:8080Docker labels
Section titled “Docker labels”Traefik reaches the container over a shared Docker network, so the published port is not used. Keep the overlay applied anyway, or the instance takes 80 and 443 away from Traefik.
Put this in traefik.compose.yml next to the stack:
services: edge: networks: - fluxer - traefik labels: traefik.enable: "true" traefik.docker.network: traefik traefik.http.routers.fluxer.rule: Host(`chat.example.com`) traefik.http.routers.fluxer.entrypoints: websecure traefik.http.routers.fluxer.tls.certresolver: letsencrypt traefik.http.services.fluxer.loadbalancer.server.port: "8080"
networks: traefik: external: trueThen list all the files so every command picks them up:
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml:traefik.compose.ymlHAProxy
Section titled “HAProxy”One frontend, one backend, and the timeouts:
defaults mode http log stdout format raw local0 option httplog timeout connect 5s timeout client 1h timeout server 1h timeout tunnel 1h
frontend fluxer bind :80 bind :443 ssl crt /etc/haproxy/certs/chat.example.com.pem alpn h2,http/1.1 http-request redirect scheme https unless { ssl_fc } http-request set-header X-Forwarded-For %[src] default_backend fluxer_edge
backend fluxer_edge server edge 127.0.0.1:8080timeout client and timeout server stop applying once a connection is upgraded, so timeout tunnel is what keeps the Gateway socket and LiveKit signalling alive. set-header replaces any header the client sent. option forwardfor appends to it. Give the frontend and the backend different names. HAProxy warns on a shared name and drops support for it in 3.3.
Apache httpd
Section titled “Apache httpd”This configuration needs these modules: mod_proxy, mod_proxy_http, and mod_headers. Since 2.4.47 mod_proxy_http handles the WebSocket upgrade itself, so mod_proxy_wstunnel is not needed.
<VirtualHost *:443> ServerName chat.example.com
SSLEngine on SSLCertificateFile /etc/letsencrypt/live/chat.example.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/chat.example.com/privkey.pem
LimitRequestBody 536870912 ProxyPreserveHost On ProxyTimeout 3600
RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"
ProxyPass / http://127.0.0.1:8080/ upgrade=websocket timeout=3600 ProxyPassReverse / http://127.0.0.1:8080/</VirtualHost>upgrade=websocket is the whole WebSocket configuration on 2.4.47 and later.
RequestHeader set replaces whatever the client sent, and the quotes around "expr=%{REMOTE_ADDR}" are required by the expression parser. ProxyAddHeaders is on by default and appends the connection address after that value. The edge reads the real address either way.
LimitRequestBody is 0 by default, which means no limit, but distribution packages and <Directory> blocks often set it lower. The 536870912 above is 512 MiB. ProxyTimeout and the timeout=3600 parameter both bound the upstream connection, and an hour covers the Gateway socket.
Cloudflare Tunnel
Section titled “Cloudflare Tunnel”One ingress rule for the hostname, with the catch-all cloudflared requires under it:
tunnel: fluxercredentials-file: /etc/cloudflared/fluxer.json
ingress: - hostname: chat.example.com service: http://127.0.0.1:8080 originRequest: connectTimeout: 30s - service: http_status:404When cloudflared runs as a container on the stack’s Docker network, point it at http://edge:8080. The published port is then never used. Compose prefixes the network name with the project name, which the stack sets to fluxer, so the network is fluxer_fluxer and an external declaration names it in full:
networks: fluxer: external: true name: fluxer_fluxerCloudflare appends the visitor address to any X-Forwarded-For the visitor sent, so the header can arrive with a forged value in front of the real one. The edge reads the rightmost address that is not in FLUXER_EDGE_TRUSTED_PROXIES, which is the one Cloudflare appended. A Transform Rule that sets X-Forwarded-For to cf.connecting_ip removes the ambiguity.
Cloudflare caps a request body at 100 MB on Free and Pro, 200 MB on Business, and 500 MB on Enterprise, and answers 413 above the cap. The cap applies to tunnel traffic, and no cloudflared setting raises it. Keep the max_attachment_file_size limit below the cap for your plan, in the admin dashboard under limit configuration. It defaults to 26214400 bytes for a non-premium account and 524288000 bytes for a premium one, which is above every cap below Enterprise.
A tunnel has the web app, API, Gateway, admin dashboard, media routes, and LiveKit signalling, but no LiveKit media.
Nginx Proxy Manager
Section titled “Nginx Proxy Manager”Add a proxy host with these values:
| Field | Value |
|---|---|
| Domain Names | The hostname chat.example.com |
| Scheme | Plain http |
| Forward Hostname / IP | 127.0.0.1, or the edge container when both run in Docker |
| Forward Port | Port 8080 |
| Websockets Support | On, and required |
| Block Common Exploits | Off, because its request filtering rejects legitimate API traffic |
| SSL | Request a certificate, then turn on Force SSL and HTTP/2 Support |
In the Advanced tab:
client_max_body_size 512m;proxy_read_timeout 3600s;proxy_send_timeout 3600s;Nginx Proxy Manager appends to X-Forwarded-For, so the visitor’s own value arrives in front of the real address. The edge takes the rightmost entry that is not in FLUXER_EDGE_TRUSTED_PROXIES, which is the one Nginx Proxy Manager appended. Users on a LAN or a VPN inside the default private_ranges are the exception and resolve to the forged entry, so narrow FLUXER_EDGE_TRUSTED_PROXIES to the proxy’s address in that layout.
What the single port routes
Section titled “What the single port routes”Forward every path and query string unchanged. The edge handles routing:
| Public path | Purpose |
|---|---|
/_health | Edge health check |
/gateway, /gateway/* | Gateway connections and health |
/api/* | HTTP API |
/media/* | Media and attachment delivery |
/livekit/* | Voice signalling |
/admin, /admin/* | Admin dashboard |
/web/*, /emoji/*, /libs/*, /avatars/*, /badges/*, /desktop/*, /embeds/* | Static assets |
/.well-known/fluxer | Instance discovery |
/.well-known/apple-app-site-association, /apple-app-site-association | Apple app association |
/.well-known/assetlinks.json | Android app association |
/version.json | Client version metadata |
All other paths serve the web app.
Pass the query string on /gateway through untouched. Clients always send ?v=, ?encoding=, ?compress= and ?stream=, and 1 is the only version the Gateway accepts.
Apple and Google require the association files at those fixed paths for saved-password autofill and app links. A proxy that forwards all paths needs no extra rules. Include them explicitly if you use a path allowlist.
/_metrics on the API, Media Proxy, Gateway, and push service, plus /_health/ready, /_health/drain, and /_health/undrain on the Gateway, are gated to loopback and are unreachable through any proxy. The push service has no public path. The probes that work through a proxy are /_health, /api/_health, /gateway/_health, and /media/_health.
Trusted proxies
Section titled “Trusted proxies”FLUXER_EDGE_TRUSTED_PROXIES names the peer addresses whose X-Forwarded-For the edge believes. It defaults to private_ranges, which Caddy expands to 192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8, 127.0.0.1/8, fd00::/8, and ::1, so a proxy on the same host or the same Docker network needs no change.
The edge resolves one client address per request and rewrites X-Forwarded-For to it on every upstream hop, so no service ever reads what a visitor sent. A peer outside the list becomes the client address, and the edge discards the header it sent. For a peer inside the list, the edge uses the rightmost header entry that is not itself trusted. A proxy that appends to the header therefore still passes on the real client address.
A proxy that reaches the instance from a public address is not trusted, so the edge discards its header and attributes every request to the proxy itself. That puts all your users in one rate limit bucket and one geolocation. Add the address, and list private_ranges too if you still need it:
FLUXER_EDGE_TRUSTED_PROXIES=private_ranges 203.0.113.10/32private_ranges does not cover 100.64.0.0/10, so a proxy reaching the instance over a Tailscale or other CGNAT tailnet address is untrusted and collapses every visitor onto that one address. Name the tailnet address of the proxy:
FLUXER_EDGE_TRUSTED_PROXIES=private_ranges 100.101.102.103/32The edge accepts whatever a trusted peer sends. Anyone who can open a TCP connection from a trusted address can set X-Forwarded-For to any value and be recorded as that address, which defeats bans and rate limits and pollutes abuse detection. Keep the list down to the one address your proxy arrives from. That is also the only correct list on a LAN or VPN deployment, where a visitor whose own address falls inside a wider list is recorded under the proxy’s address.
Find the address the edge sees a host-side proxy arrive from:
docker network inspect fluxer_fluxer -f '{{(index .IPAM.Config 0).Gateway}}'The edge reads the variable at container start, so apply a change with docker compose up -d.
Caching in front of FiveCord
Section titled “Caching in front of FiveCord”Media reads are the only traffic worth caching, and the instance expects no cache in front of it. Every rule below is a correctness rule rather than a tuning choice, and a cache that cannot follow one should not store media at all.
Leave the signature parameters out of the cache key
Section titled “Leave the signature parameters out of the cache key”ex, is, hm, and uc are the signed attachment URL parameters. A key that keeps them stores a new entry every time the same object is signed again, which empties the cache of anything useful. Drop all four and keep every other parameter, because the rest select the representation.
Only drop them if your proxy also meets the next two rules. A key without them is the same key for a signed request and an unsigned one, so the signature check is the only thing between an unsigned request and the object.
Decode parameter names, and take the last value
Section titled “Decode parameter names, and take the last value”FiveCord parses a query string with form URL decoding, so %73ize is the name size and + is a space. When a name repeats, the final value wins. A cache that compares raw bytes, or that keeps the first value, can be handed %73ize=64 or size=64&size=4096 and made to store one rendering under the key for another. Match both rules, or refuse to cache a request whose query string you cannot parse that way.
Check the signature before the cache lookup
Section titled “Check the signature before the cache lookup”A cache hit never reaches the instance, so the Media Proxy’s own signature check never runs on one. Under the enforce signed attachment URL policy, verify the signature in the proxy before it looks in the cache. An nginx access_by_lua_block runs in the access phase, which is before the cache lookup. A proxy that cannot do that must leave /attachments/ uncached, or it will serve an expired or forged URL from a warm entry.
Verify the same path the cache key is built from
Section titled “Verify the same path the cache key is built from”Running before the cache lookup is not enough on its own. The check and the key must read the same spelling of the path, or a request the check skips still reaches the entry the key names.
In nginx that spelling is $uri, which nginx has already percent decoded, merged repeated slashes in, and resolved . and .. out of. $request_uri is the raw bytes the client sent. A check that scopes itself on $request_uri never sees //attachments/..., /attachments%2F..., /attachments/./..., or /x/../attachments/... as an attachment read, while $uri and the cache key make all four the same object. Read $uri for the prefix test and for the storage key, take the query from $request_uri, and refuse a path whose $uri is not a usable storage key rather than passing it through.
The signature covers the decoded storage key, which is $uri without its leading slash.
Do not cache a range request without slice support
Section titled “Do not cache a range request without slice support”nginx removes Range from a request it may cache unless the slice module is built in, so a seek into a video pulls the whole object. Set proxy_cache_max_range_offset 0. A range request then reaches the instance untouched and is not stored, while an entry that is already warm still answers ranges from cache.
Wire up purge, and bound the entry lifetime anyway
Section titled “Wire up purge, and bound the entry lifetime anyway”Every media 200 is public, max-age=31536000 with no ETag and no Last-Modified, so a cache never revalidates and a deleted object stays until its entry goes. Point FLUXER_CACHE_PURGE_HTTP_ENDPOINT at a receiver that clears your cache, and read the contract it must meet under Cache purge. Give stored media a shorter lifetime of your own as well, because a purge that is lost is never sent again.
Turning the signature policy on
Section titled “Turning the signature policy on”FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE is the instance switch, and a proxy that verifies signatures has one of its own. Both sides name the same three states, off, report, and enforce. Both read their setting once at start, so apply an instance change with docker compose up -d media-proxy, which replaces the container. docker compose restart media-proxy reuses the old environment and changes nothing.
Turn the policy on in this order, and stop wherever a refusal appears that you did not expect:
- Give
api,worker, andmedia-proxythe sameFLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64, and give the proxy the same list. Every URL the instance returns is signed from here on, and nothing verifies yet. - Leave it there until the clients holding the unsigned URLs issued before step 1 have replaced them. A client refreshes a URL whose
exis absent or under an hour away, and one that has been open since before step 1 keeps its unsigned URLs until it reloads. - Set both sides to
reportand read the logs. Underreportnothing is refused, and each readenforcewould refuse is logged with its reason on the side that saw it. - Move the proxy to
enforceoncereportis quiet. - Move the instance to
enforce.
The proxy goes first because a cache hit never reaches the instance. Reversing steps 4 and 5 leaves every warm entry readable without a signature for as long as it lives.
From step 3 the instance caps Cache-Control on a read whose signature is valid at the time that signature has left, where off returns a year. report caps it too, so the entries stored during the dry run do not outlive the URLs that made them. A read report would refuse still returns a year, because there is no signature to cap it against. Entries stored before step 3 keep the year they were given.
When the two sides disagree
Section titled “When the two sides disagree”| Proxy | Instance | What an attachment read gets |
|---|---|---|
off | off | Served. Neither side reads a signature |
off | report | Served. The instance logs what enforce would refuse |
off | enforce | Refused, unless a warm entry answers it without reaching the instance |
report | off | Served. The proxy logs what its own enforce would refuse |
report | report | Served. Both sides log |
report | enforce | Refused, unless a warm entry answers it without reaching the instance |
enforce | off | Refused at the proxy, before the cache lookup |
enforce | report | Refused at the proxy. The instance logs nothing, because it is never asked |
enforce | enforce | Refused at the proxy |
The two rows that serve a read the instance would refuse are the ones where the instance enforces alone. The instance is right and the cache is stale, and the entry answers until it goes, so pair an instance-first change with a purge of /attachments/.
A change to FLUXER_MEDIA_PROXY_CORS_MODE has the same problem and no step order that avoids it. An entry stored while the policy was off holds Access-Control-Allow-Origin: * and a year of Cache-Control, and the proxy replays both on every hit without asking the instance. The proxy’s own signature check does not help, because it reads the signature and never reads Origin, so a foreign page holding one valid signed URL still reads the bytes cross-origin from a warm entry. Purge /attachments/ when you move the CORS policy to enforce, the same purge step 5 needs.
enforce at the proxy alone covers every request that passes through it. Anything that can reach the instance another way is unchecked until the instance enforces too: a second cache, a health probe from outside the proxy, an internal service that reads an attachment URL directly, and a deployment running no proxy at all. Those are what step 5 covers, and what it can break.
Step 4 also ends the instance’s own evidence. A read the proxy refuses never reaches the instance, so once the proxy enforces the instance logs nothing under report and its stream stays empty whatever it would have refused. Take step 5 on what step 3 showed, while both sides were still in report.
Both sides must hold the same secret list. A side missing a key refuses the URLs signed with it, so add a new key to both sides before the instance starts signing with it, and keep a retired key on both until the last URL it signed has expired. An ordinary URL expires within a day. A data package URL never does, and removing the key that signed it is what ends it.
With no secret list, api and worker return unsigned URLs, and a side in enforce refuses every attachment read. Never move a side to enforce before signing is on.
Voice media does not use the proxy
Section titled “Voice media does not use the proxy”LiveKit signalling goes through /livekit/* like everything else. WebRTC media does not touch the proxy at all:
7882/udpis media.7881/tcpis the fallback when UDP is blocked.
Both ports are published directly by the stack and must reach the host. A proxy or tunnel in front of 443 does nothing for them.
Hosting LiveKit on a hostname other than FLUXER_DOMAIN means widening the Content-Security-Policy the web app runs under. The line goes in .env, beside FLUXER_DOMAIN:
FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881app-proxy builds the policy and reads its environment at container start, so apply the change with docker compose up -d app-proxy. docker compose restart app-proxy reuses the existing container with its old environment. Content Security Policy has the other variables.