Fluxer is moving quickly, and the self-hosting story is still changing. I wanted a setup that fit an existing homelab instead of asking Fluxer to own ports 80/443 directly:
- Fluxer runs as a single Portainer stack on one Docker host.
- My existing Nginx reverse proxy terminates TLS.
- Fluxer's bundled/internal Caddy edge stays in place and handles Fluxer's internal path routing.
- LiveKit handles voice/video, with signaling proxied through Nginx and WebRTC media ports forwarded directly to the Fluxer host.
- Email can optionally go through an internal Stalwart SMTP relay.
- Windows users can launch the official Fluxer Canary desktop app against the self-hosted instance so native features such as global push-to-talk remain available.
The Docker deployment was the easy part. What actually consumed my time was the Windows desktop client: it would load the instance, connect to LiveKit signaling, and open the microphone, and voice would still fail.
TL;DR
The setup is conventional once the pieces are separated. Nginx owns TLS, Fluxer's internal Caddy owns application routing, Portainer owns the stack, and LiveKit owns realtime media on two directly forwarded ports.
The part worth reading, if you only read one thing, is the voice bug. Fluxer Canary was creating WebRTC connections with:
iceTransportPolicy: "relay"
while my LiveKit configuration supplied STUN servers and no TURN server. In plain English:
relay-only WebRTC + no TURN relay = no usable ICE route
Signaling was fine. The microphone was fine. ICE was the failure point, and no amount of reverse-proxy tuning was going to fix it.
The workaround is a small same-origin JavaScript shim, injected into Fluxer's HTML by Nginx, that flips the policy to all when it sees relay-only ICE with no TURN server configured. That lets normal host/STUN candidates participate again. Skip to the fix if that is why you are here.
Everything else below is the full build, in order, with the long configuration files tucked into expandable sections so you can ignore them until you need them.
Version note: this was tested against Fluxer's self-hosting layout and the Canary desktop client in September 2026. Fluxer is under active development, and the voice workaround exists because of a client-side bug. Compare this with upstream before applying it months later, and drop the shim once upstream no longer needs it.
The topology
The important design choice is that Nginx is the public edge, but it does not try to replace all of Fluxer's internal routing.
Internet
|
| TCP 443 / HTTPS
v
Nginx reverse proxy
|
| HTTP to LAN
v
Fluxer host :8080
|
v
Fluxer internal Caddy edge
|-- /api/* -> API
|-- /gateway/* -> realtime gateway
|-- /media/* -> media proxy
|-- /livekit/* -> LiveKit :7880
|-- /admin/* -> admin app
`-- everything else -> web app
LiveKit WebRTC media bypasses Nginx:
Internet -> TCP 7881 / UDP 7882 -> Fluxer host -> LiveKit
Keeping Fluxer's internal edge is useful because Fluxer's upstream Caddyfile already knows the application's path layout. The public Nginx server only has to preserve the important headers and WebSocket upgrades. This is also the general pattern other self-hosters have used successfully with an existing reverse proxy.
What you need
For the example below I will use these sanitized values:
| Purpose | Example |
|---|---|
| Fluxer public name | chat.example.com |
| Fluxer Docker host | 10.0.0.50 |
| Nginx reverse proxy | any host that can reach 10.0.0.50:8080 |
| Optional Stalwart host | 10.0.0.60 |
| Fluxer edge host port | 8080 |
| LiveKit ICE/TCP | 7881/tcp |
| LiveKit ICE/UDP | 7882/udp |
You also need working public DNS and a valid TLS certificate for chat.example.com on the Nginx host.
1. Generate the secrets first
The Portainer stack below intentionally keeps its secrets inline because the goal is a single-file stack. That is convenient for Portainer, but it also means the stack definition itself is sensitive.
For most random secrets, this is sufficient:
openssl rand -hex 32
For the media upload relay secret, generate base64:
openssl rand -base64 32
VAPID is different: the public and private values must be a matching keypair, so do not generate the two halves independently. One easy way to generate them is:
docker run --rm node:24-alpine \
npx --yes web-push generate-vapid-keys --json
Replace every CHANGE_ME_* value in the stack before deploying it.
A small but important database warning: if you already have a populated postgres-data volume, changing POSTGRES_PASSWORD in Compose does not magically change the password of the PostgreSQL role that already exists inside that volume. Keep the old value or change the database role password deliberately.
2. Deploy Fluxer as a Portainer stack
In Portainer:
- Open Stacks.
- Create a new stack, for example
fluxer. - Use the Web editor.
- Paste the stack from Appendix B.
- Replace
chat.example.com, SMTP settings if used, and everyCHANGE_ME_*secret. - Deploy the stack.
The key external-facing pieces are intentionally simple:
edge:
image: caddy:2.10-alpine
ports:
- "8080:80"
environment:
FLUXER_EDGE_SITE_ADDRESS: ":80"
FLUXER_EDGE_TRUSTED_PROXIES: private_ranges
TLS does not terminate here. Nginx talks to this edge over the LAN on port 8080.
The LiveKit service exposes only the WebRTC media ports directly:
livekit:
image: livekit/livekit-server:v1.12.0
environment:
LIVEKIT_CONFIG: |
port: 7880
log_level: info
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: true
webhook:
api_key: CHANGE_ME_LIVEKIT_API_KEY
urls:
- http://api:8080/webhooks/livekit
ports:
- "7881:7881/tcp"
- "7882:7882/udp"
Port 7880 is the LiveKit signaling/API endpoint. It stays behind the Fluxer edge and Nginx rather than being exposed directly.
Note the stun_servers entries in the full stack, and the absence of any TURN server. That combination is exactly what the desktop client trips over later in step 8.
3. Put Nginx in front of Fluxer
The public Nginx host terminates TLS and forwards traffic to http://10.0.0.50:8080.
I split out the realtime paths mostly to make the WebSocket requirements obvious. The catch-all still goes to Fluxer's internal edge, which is what actually decides whether a request belongs to the API, admin UI, static proxy, or web app.
/gateway and /livekit are the two locations that need WebSocket upgrades:
location /gateway {
proxy_pass http://10.0.0.50:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
For Nginx 1.24, enable HTTP/2 on the listener with:
listen 443 ssl http2;
On newer Nginx releases that use the newer directive style, use listen 443 ssl; together with http2 on;.
HTTP/2 is worth checking. A chat UI performs a large number of small requests, and an outside user can feel WAN round-trip latency much more strongly when the site is accidentally serving only HTTP/1.1, even when the internet connection itself has plenty of bandwidth.
The complete file, including the WebRTC shim wiring added in step 8, is in Appendix A.
Validate and reload it:
nginx -t
systemctl reload nginx
Then verify that the outside connection is actually using HTTP/2:
curl -I --http2 https://chat.example.com
The first line should be an HTTP/2 response.
A couple of useful sanity checks are:
curl https://chat.example.com/_health
curl https://chat.example.com/.well-known/fluxer
4. DNS and firewall/NAT rules
Normal web traffic is straightforward:
TCP 80/443 -> Nginx reverse proxy
Voice/video requires two additional forwards directly to the Fluxer/LiveKit host:
TCP 7881 -> 10.0.0.50:7881
UDP 7882 -> 10.0.0.50:7882
Do not forward LiveKit port 7880 to the internet. Signaling already travels through:
wss://chat.example.com/livekit
which reaches Nginx on 443, then Fluxer's internal edge, then LiveKit on 7880.
The current upstream self-hosting Compose uses the same 7881/tcp and 7882/udp model and enables use_external_ip for LiveKit.
5. Open the Fluxer admin UI
The admin application is served through the same public hostname:
https://chat.example.com/admin
No separate Nginx location is required in this design. The catch-all sends /admin to Fluxer's Caddy edge, and the edge routes it to the admin container.
6. Optional: SMTP through an internal Stalwart relay
Fluxer needs SMTP for things such as registration/password-related mail if email is enabled. In my setup, Stalwart is on a different LAN host and has a dedicated listener for Fluxer.
I used an internal listener on port 2526 with TLS disabled because the traffic never leaves the trusted LAN. The important part is not to turn it into an open relay.
Stalwart relay rules and the matching Fluxer settings
In Stalwart's AUTH stage, the logic was effectively:
IF remote_ip == '10.0.0.50'
THEN false
ELSE local_port != 25
That exempts the Fluxer host from authentication while leaving other clients subject to authentication rules.
Then in the RCPT TO stage, the relay rule was:
IF remote_ip == '10.0.0.50'
THEN true
ELSE !is_empty(authenticated_as)
The dedicated Stalwart listener is bound to 2526, and Docker publishes 2526:2526. Restrict that listener with your LAN firewall so random machines cannot use it.
Fluxer then uses:
FLUXER_EMAIL_ENABLED: "true"
FLUXER_EMAIL_PROVIDER: smtp
FLUXER_EMAIL_FROM_EMAIL: noreply@example.com
FLUXER_EMAIL_FROM_NAME: Fluxer
FLUXER_EMAIL_APP_BASE_URL: https://chat.example.com
FLUXER_EMAIL_SMTP_HOST: "10.0.0.60"
FLUXER_EMAIL_SMTP_PORT: "2526"
FLUXER_EMAIL_SMTP_USERNAME: ""
FLUXER_EMAIL_SMTP_PASSWORD: ""
FLUXER_EMAIL_SMTP_SECURE: "false"
Before blaming Fluxer, test the relay from the Fluxer host itself with telnet, nc, or an SMTP test tool and verify that an external recipient is accepted.
7. Point the Windows desktop client at the instance
The official desktop client currently does not expose a polished self-hosted instance picker in the released UI. Fluxer's tracking issue for connecting the desktop app to self-hosted instances is still open, while the Canary client is where the newest desktop work lands.
For Windows, install Fluxer Canary from:
https://canary.fluxer.app/download
The normal installed executable is currently under:
%LOCALAPPDATA%\fluxer_desktop_canary\current\Fluxer Canary.exe
The useful discovery is that Canary build 2026.913.210037 accepts an undocumented one-shot command-line override:
--fluxer-app-url="https://chat.example.com"
So from PowerShell:
& "$env:LOCALAPPDATA\fluxer_desktop_canary\current\Fluxer Canary.exe" --fluxer-app-url="https://chat.example.com"
That launches the real native Fluxer client against the self-hosted web app rather than wrapping the site in a generic browser window. That matters if you want desktop-native functionality such as global keyboard handling for push-to-talk while a game has focus.
One important behavior: if Fluxer is already running, launching it again may hand the URL off to the existing process and the one-shot override may not take effect. Fully quit Fluxer first, then use the self-hosted shortcut.
PowerShell script to create permanent Desktop and Start Menu shortcuts
A shortcut is easier than asking everyone to remember a PowerShell command. This script creates Desktop and Start Menu shortcuts that point to the current install directory, which also makes it reasonably update-friendly.
param(
[string]$InstanceUrl = "https://chat.example.com",
[string]$ShortcutName = "My Fluxer"
)
$ErrorActionPreference = "Stop"
$CanaryExe = Join-Path $env:LOCALAPPDATA "fluxer_desktop_canary\current\Fluxer Canary.exe"
if (-not (Test-Path $CanaryExe)) {
Write-Host "Fluxer Canary was not found at:" -ForegroundColor Red
Write-Host " $CanaryExe"
Write-Host "Install Fluxer Canary first, then run this script again."
exit 1
}
$Desktop = [Environment]::GetFolderPath("Desktop")
$StartMenu = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"
$WshShell = New-Object -ComObject WScript.Shell
function New-FluxerShortcut {
param([string]$Path)
$Shortcut = $WshShell.CreateShortcut($Path)
$Shortcut.TargetPath = $CanaryExe
$Shortcut.Arguments = "--fluxer-app-url=`"$InstanceUrl`""
$Shortcut.WorkingDirectory = Split-Path -Parent $CanaryExe
$Shortcut.IconLocation = "$CanaryExe,0"
$Shortcut.Description = $ShortcutName
$Shortcut.Save()
}
New-FluxerShortcut (Join-Path $Desktop "$ShortcutName.lnk")
New-FluxerShortcut (Join-Path $StartMenu "$ShortcutName.lnk")
Write-Host "Created Desktop and Start Menu shortcuts for $InstanceUrl" -ForegroundColor Green
Write-Host "If Fluxer Canary is already running, fully quit it before using the new shortcut." -ForegroundColor Yellow
Save that as Install-FluxerSelfHosted.ps1, change the default URL/name, and run it after installing Canary.
At this point the desktop client loads the self-hosted instance and text chat works. Voice, in my case, did not.
8. The fix that actually mattered: relay-only ICE with no TURN
This is the part that took the longest to find, and it is the reason this post exists.
The symptoms were misleading, because almost everything looked healthy:
- The client loaded the self-hosted instance.
- The LiveKit signaling WebSocket connected and returned
101 Switching Protocols. - Microphone capture worked.
- Voice still failed, with no usable ICE candidates.
The cause was that Fluxer Canary was requesting relay-only WebRTC:
iceTransportPolicy: "relay"
A relay-only policy tells WebRTC to discard host and STUN candidates and use only TURN relay candidates. My LiveKit configuration had STUN servers and no TURN server at all, so there were no relay candidates to use, and ICE had nothing left to try:
Fluxer Canary
-> asks for relay-only ICE
-> no TURN server exists
-> no usable relay candidates
-> LiveKit peer connection fails
The shim
The workaround is ordinary browser JavaScript that wraps RTCPeerConnection, the object responsible for WebRTC. It only touches the native Electron client, and it only intervenes in the specific broken case: a relay-only policy with no turn: or turns: server present.
The decision itself is this small:
const hasTurn = urls.some(url => /^turns?:/i.test(url));
// Fluxer Canary may request relay-only ICE even when
// the server supplies only STUN and no TURN.
if (config.iceTransportPolicy === "relay" && !hasTurn) {
return { ...config, iceTransportPolicy: "all" };
}
With the policy set back to all, host and STUN candidates are allowed to participate and ICE can complete:
Fluxer Canary
-> shim sees relay-only + no TURN
-> changes the policy to "all"
-> host/STUN candidates are allowed
-> WebRTC can connect
It also wraps setConfiguration() so the fix still applies if the client changes its WebRTC configuration after creating the connection.
Save the full script as /etc/nginx/fluxer-webrtc-fix.js:
Show the complete fluxer-webrtc-fix.js
(() => {
// Only touch the native Fluxer/Electron client.
if (!/Electron|FluxerCanary/i.test(navigator.userAgent)) return;
const NativeRTC = window.RTCPeerConnection;
if (!NativeRTC) return;
function fixConfig(config) {
if (!config) return config;
const servers = Array.isArray(config.iceServers)
? config.iceServers
: [];
const urls = servers
.flatMap(server =>
Array.isArray(server.urls)
? server.urls
: [server.urls]
)
.filter(Boolean)
.map(String);
const hasTurn = urls.some(url => /^turns?:/i.test(url));
// Fluxer Canary may request relay-only ICE even when
// the server supplies only STUN and no TURN.
if (config.iceTransportPolicy === "relay" && !hasTurn) {
console.info(
"[Fluxer RTC Fix] relay-only without TURN -> forcing all"
);
return {
...config,
iceTransportPolicy: "all"
};
}
return config;
}
window.RTCPeerConnection = new Proxy(NativeRTC, {
construct(Target, args) {
if (args.length > 0) {
args[0] = fixConfig(args[0]);
}
const pc = Reflect.construct(Target, args);
// Cover setConfiguration() too, in case Fluxer changes it later.
if (typeof pc.setConfiguration === "function") {
const nativeSetConfiguration =
pc.setConfiguration.bind(pc);
pc.setConfiguration = config =>
nativeSetConfiguration(fixConfig(config));
}
return pc;
}
});
console.info("[Fluxer RTC Fix] active");
})();
Injecting it with Nginx
To be clear about what this is: it is a normal browser script served to the client, not the Nginx njs module. Nginx does two things. It serves the file from the same Fluxer hostname, and it inserts a <script> tag before </head> in Fluxer's HTML.
Serving it from the Fluxer hostname keeps it same-origin, which fits the normal 'self' Content Security Policy pattern.
location = /fluxer-webrtc-fix.js {
alias /etc/nginx/fluxer-webrtc-fix.js;
default_type application/javascript;
add_header Cache-Control "no-store";
}
Then, inside the main location /:
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter_types text/html;
sub_filter '</head>' '<script src="/fluxer-webrtc-fix.js"></script></head>';
Accept-Encoding "" is the line people miss. sub_filter can only rewrite an uncompressed HTML body, so clearing the header upstream is what makes the injection work at all.
Reload and confirm:
nginx -t
systemctl reload nginx
In Canary DevTools, the useful confirmation is:
[Fluxer RTC Fix] active
[Fluxer RTC Fix] relay-only without TURN -> forcing all
The first line means the shim loaded. The second means it found the broken configuration and corrected it.
Treat this as temporary
This is a client-side bug worked around from the server side. It is the smallest intervention I could find that did not involve modifying the packaged client, but it is still a workaround:
- Remove it once upstream stops requesting relay-only ICE without a TURN server.
- A real TURN deployment is the durable answer if you need relay for other reasons; see the TURN note below.
Why not just patch app.asar?
Worth separating two different JavaScript topics, because I went down both paths and they are unrelated.
The shim above fixes WebRTC. Before I found the --fluxer-app-url flag in step 7, I was also looking at patching the packaged client just to select the instance URL, which is a completely different problem.
In Canary 2026.913.210037, the packaged dist/main/MainApp.js contains the stable and Canary defaults in minified JavaScript, conceptually equivalent to:
const STABLE_APP_URL = "https://web.fluxer.app";
const CANARY_APP_URL = "https://web.canary.fluxer.app";
and the selected URL is effectively:
return appUrlOverride || (channel === "canary" ? CANARY_APP_URL : STABLE_APP_URL);
So the naive patch would be to rewrite the Canary default to https://chat.example.com. But the same packaged code also contains a parser for --fluxer-app-url, and the main process logs that it is using a one-shot app URL override from the command line. Once I found that, patching the ASAR stopped making sense:
- Fluxer updates can overwrite
app.asar. - Repacking an ASAR can affect integrity metadata or code-signing expectations.
- You now own the modified client instead of simply launching the upstream binary differently.
- The command-line flag is much easier to roll out and undo.
If a future build removes the command-line switch, I would rather build the desktop client from source with the instance URL changed deliberately than maintain a binary patch against minified JavaScript.
Troubleshooting, in the order that actually helps
If voice fails, work through it in this order. The point is to stop guessing at Nginx before you know which layer is broken:
1. Can the client open wss://chat.example.com/livekit?
2. Does the WebSocket receive HTTP 101 Switching Protocols?
3. Does microphone capture work?
4. Does ICE gather usable candidates?
5. Are 7881/tcp and 7882/udp reachable externally?
A working WebSocket does not prove WebRTC media is working. Signaling and media are separate paths, and conflating them is what sent me rewriting reverse-proxy config for no reason.
The web app works, but LiveKit voice does not. Look at the LiveKit logs. A healthy attempt should at least show a session starting and ICE candidates. With use_external_ip: true, an internet-facing server commonly advertises candidates resembling:
udp4 host PUBLIC_IP:7882
tcp4 host PUBLIC_IP:7881
If the user is outside your LAN and those ports are forwarded correctly, test UDP 7882 first. UDP is the preferred media path; 7881/TCP is the fallback.
Nginx shows 101 Switching Protocols, but voice still fails. A 101 on /livekit/rtc/... proves that the WebSocket signaling path reached LiveKit. It does not prove the WebRTC media connection succeeded. That distinction saved a lot of unnecessary reverse-proxy changes.
Browser console says WebRTC: ICE failed. That points at ICE candidate reachability. For an internal user, check the LAN/public candidate situation described below. For an external user, check the port forwards and firewall.
Desktop app reaches LiveKit but produces no ICE candidates. This is the bug fixed in step 8. The tell is that the browser works on the same machine and the desktop client does not, while signaling and microphone access are both fine. We verified microphone capture separately with:
navigator.mediaDevices.getUserMedia({audio:true})
and still saw the desktop client's peer connection finish ICE gathering without a usable candidate. If you see that, check the client's iceTransportPolicy before touching Nginx or your firewall.
Screenshare gives unsupported codec or could not get mid for track. The Canary client is still rough around some screenshare paths. In one test, LiveKit saw contradictory VP9/H.264/VP8 negotiation and could not bind the published track to the remote subscriber. Lower resolution/30 FPS and disabling shared audio are useful tests, but this can be a client bug rather than a server configuration error. Fluxer's own Canary tracking notes have explicitly called out screensharing as an area still being fixed.
The UI is fast on the LAN but sluggish for outside users. Check the protocol before blaming bandwidth:
curl -I --http2 https://chat.example.com
On Nginx 1.24, accidentally using only listen 443 ssl; means HTTP/2 is not enabled. A gigabit connection can still feel slow if every interaction pays unnecessary WAN round trips and request queueing.
LAN, TURN, and screen sharing caveats
LAN voice. I use split DNS so internal clients resolve chat.example.com to the LAN address of the Nginx proxy instead of hairpinning through the public WAN address. That handles the website and WebSocket signaling nicely, but LiveKit may still advertise the public server IP as its ICE candidate. If an internal browser cannot establish voice while external users can, this is an ICE routing problem, not an Nginx problem.
LiveKit has an optional setting for mixed private/public environments:
rtc:
use_external_ip: true
advertise_internal_ip: true
That tells LiveKit to keep an internal candidate alongside the mapped public candidate. Test it carefully in Docker, because you want the useful LAN address advertised rather than only a Docker bridge address. I prefer solving the candidate problem this way or with TURN rather than enabling blanket NAT reflection simply to make WebRTC work.
TURN. The JavaScript shim is a workaround for a client requesting relay-only transport when no TURN server is present. It is not a substitute for TURN. A real TURN deployment is still useful or necessary for restrictive NATs, corporate networks, CGNAT and tunnel designs, and any environment where direct ICE cannot succeed. If you add TURN later, the shim stops intervening on its own, because it only acts when no turn: server is configured.
Screen sharing. Canary screen-sharing failures involving conflicting VP9/H.264/VP8 negotiation are a separate client/WebRTC problem. Errors such as codec is not supported by remote or could not get mid for track are not evidence that the Nginx WebSocket proxy is broken.
Updating the stack
Fluxer's v1 container tags move as new builds ship. That is convenient for following upstream but means an update can change behavior.
Before updating a working instance:
- Back up PostgreSQL and the persistent Docker volumes.
- Save the Portainer stack definition.
- Compare upstream
deploy/self-hosting/docker-compose.ymlandCaddyfilewith your current version. - Pull/redeploy during a window where you can test login, uploads, gateway connectivity, voice, and admin immediately afterward.
- Re-check whether the WebRTC shim is still needed, and remove it when it is not.
- Keep an eye on the desktop self-hosting issue because the
--fluxer-app-urlworkaround may eventually be replaced by a proper instance selector.
Final result
The end state is pleasantly conventional once the moving pieces are separated:
Nginx owns TLS and HTTP/2.
Fluxer's internal Caddy owns application routing.
Portainer owns the application stack.
LiveKit owns realtime media.
The router exposes only the required LiveKit media ports.
Windows Canary uses --fluxer-app-url to point at the self-hosted instance.
A small same-origin shim keeps relay-only ICE from breaking desktop voice.
The biggest architectural lesson was not to flatten everything into Nginx. Keeping Fluxer's own internal edge lets the upstream application continue to own its routing shape, while Nginx stays a normal TLS reverse proxy in front of it.
The biggest debugging lesson was that a healthy WebSocket tells you nothing about whether media will flow. Signaling and ICE are different layers, and in this case the answer was in the client's WebRTC configuration rather than anywhere in my infrastructure.
Appendix A: complete sanitized Nginx config
Show nginx-chat.conf
# /etc/nginx/sites-enabled/chat.conf
# Sanitized example: Fluxer behind an existing Nginx TLS reverse proxy.
# Syntax shown for nginx 1.24.x.
server {
listen 80;
server_name chat.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name chat.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 512M;
# Same-origin JavaScript shim for the Fluxer Canary WebRTC bug.
location = /fluxer-webrtc-fix.js {
alias /etc/nginx/fluxer-webrtc-fix.js;
default_type application/javascript;
add_header Cache-Control "no-store";
}
# Fluxer realtime Gateway - WebSocket
location /gateway {
proxy_pass http://10.0.0.50:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# LiveKit signaling - WebSocket
location /livekit {
proxy_pass http://10.0.0.50:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# Media uploads/downloads
location /media {
proxy_pass http://10.0.0.50:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_request_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# Web app, API, admin, static assets, discovery, etc.
location / {
proxy_pass http://10.0.0.50:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
# sub_filter needs an uncompressed upstream HTML body.
proxy_set_header Accept-Encoding "";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# Inject the WebRTC workaround before </head>.
sub_filter_once on;
sub_filter_types text/html;
sub_filter '</head>' '<script src="/fluxer-webrtc-fix.js"></script></head>';
}
}
Appendix B: complete sanitized Portainer stack
This follows the upstream multi-service layout, keeps the Caddy configuration generated inside the stack, uses persistent Valkey/NATS/PostgreSQL/Meilisearch/SeaweedFS volumes, and intentionally omits Docker resource limits. Every real domain, address, password, key, and token has been replaced with an example value or a CHANGE_ME_* placeholder.
Show fluxer-portainer-stack.yml
# Fluxer - single-file Portainer Stack (sanitized publication example)
# Sanitized example based on Fluxer upstream self-hosting layout as checked 2026-09-15.
# Target: Portainer "Docker Standalone" Stack / Docker Compose v2.
# Public URL: https://chat.example.com
# External Nginx reverse proxy target: http://<FLUXER_HOST_LAN_IP>:8080
#
# DESIGN CHOICES:
# - All credentials intentionally live in this Compose file.
# - No Docker/Compose resource limits are set.
# - Caddy config is generated into a named volume so the deployment remains one YAML file.
# - LiveKit uses LIVEKIT_CONFIG directly, matching current upstream behavior.
#
# IMPORTANT:
# - This file contains credentials. Restrict access to the Portainer stack definition.
# - If reusing an EXISTING postgres-data volume, changing POSTGRES_PASSWORD here does
# not change the password of the already-created PostgreSQL role. See deployment note.
name: fluxer
# Centralized scalar anchors keep duplicated credentials in one editable location.
x-secrets:
postgres-password: &postgres-password "CHANGE_ME_POSTGRES_PASSWORD"
meili-master-key: &meili-master-key "CHANGE_ME_MEILI_MASTER_KEY"
s3-access-key: &s3-access-key "CHANGE_ME_S3_ACCESS_KEY"
s3-secret-key: &s3-secret-key "CHANGE_ME_S3_SECRET_KEY"
livekit-api-key: &livekit-api-key "CHANGE_ME_LIVEKIT_API_KEY"
livekit-api-secret: &livekit-api-secret "CHANGE_ME_LIVEKIT_API_SECRET"
smtp-password: &smtp-password "" # leave blank for IP-authorized relay, or set an SMTP password
sudo-mode-secret: &sudo-mode-secret "CHANGE_ME_SUDO_MODE_SECRET"
connection-initiation-secret: &connection-initiation-secret "CHANGE_ME_CONNECTION_INITIATION_SECRET"
vapid-public-key: &vapid-public-key "CHANGE_ME_VAPID_PUBLIC_KEY"
vapid-private-key: &vapid-private-key "CHANGE_ME_VAPID_PRIVATE_KEY"
gateway-rpc-auth-token: &gateway-rpc-auth-token "CHANGE_ME_GATEWAY_RPC_AUTH_TOKEN"
media-proxy-secret-key: &media-proxy-secret-key "CHANGE_ME_MEDIA_PROXY_SECRET_KEY"
media-proxy-upload-relay-secret: &media-proxy-upload-relay-secret "CHANGE_ME_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64"
admin-secret-key-base: &admin-secret-key-base "CHANGE_ME_ADMIN_SECRET_KEY_BASE"
admin-oauth-client-secret: &admin-oauth-client-secret "CHANGE_ME_ADMIN_OAUTH_CLIENT_SECRET"
erlang-cookie: &erlang-cookie "CHANGE_ME_ERLANG_COOKIE"
x-fluxer-postgres-env: &fluxer-postgres-env
FLUXER_DATABASE_BACKEND: postgres
FLUXER_POSTGRES_HOST: postgres
FLUXER_POSTGRES_PORT: "5432"
FLUXER_POSTGRES_DATABASE: fluxer
FLUXER_POSTGRES_USERNAME: fluxer
FLUXER_POSTGRES_PASSWORD: *postgres-password
FLUXER_POSTGRES_SSL: "false"
FLUXER_POSTGRES_PREPARED_STATEMENTS: "true"
x-fluxer-env: &fluxer-env
<<: *fluxer-postgres-env
# Core / public instance
FLUXER_ENV: production
NODE_ENV: production
LOG_LEVEL: info
FLUXER_SELF_HOSTED: "true"
FLUXER_BASE_DOMAIN: chat.example.com
FLUXER_PUBLIC_SCHEME: https
FLUXER_PUBLIC_PORT: "443"
FLUXER_PUBLIC_ORIGIN: https://chat.example.com
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
FLUXER_API_HEADERS_TIMEOUT_MS: "30000"
FLUXER_API_REQUEST_TIMEOUT_MS: "120000"
# Valkey / NATS
FLUXER_KV_URL: redis://valkey:6379/0
FLUXER_NATS_URL: nats://nats:4222
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
FLUXER_NATS_AUTH_TOKEN: ""
FLUXER_SVC_NATS_URL: nats://nats:4222
FLUXER_SVC_SHARD_COUNT: "1"
# Search
FLUXER_SEARCH_ENGINE: meilisearch
FLUXER_SEARCH_URL: http://meilisearch:7700
FLUXER_SEARCH_API_KEY: *meili-master-key
# S3 / SeaweedFS
FLUXER_S3_ENDPOINT: http://seaweedfs:8333
FLUXER_S3_PUBLIC_ENDPOINT: http://seaweedfs:8333
FLUXER_S3_REGION: us-east-1
FLUXER_S3_ACCESS_KEY_ID: *s3-access-key
FLUXER_S3_SECRET_ACCESS_KEY: *s3-secret-key
FLUXER_S3_FORCE_PATH_STYLE: "true"
FLUXER_S3_BUCKET_CDN: fluxer
FLUXER_S3_BUCKET_UPLOADS: fluxer-uploads
FLUXER_S3_BUCKET_DOWNLOADS: fluxer-downloads
FLUXER_S3_BUCKET_REPORTS: fluxer-reports
FLUXER_S3_BUCKET_HARVESTS: fluxer-harvests
AWS_ACCESS_KEY_ID: *s3-access-key
AWS_SECRET_ACCESS_KEY: *s3-secret-key
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: "true"
# LiveKit / voice
FLUXER_LIVEKIT_ENABLED: "true"
FLUXER_LIVEKIT_API_KEY: *livekit-api-key
FLUXER_LIVEKIT_API_SECRET: *livekit-api-secret
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
FLUXER_LIVEKIT_URL: https://chat.example.com/livekit
# Optional GIF integration
FLUXER_KLIPY_API_KEY: ""
# Email
FLUXER_EMAIL_ENABLED: "false"
FLUXER_EMAIL_PROVIDER: smtp
FLUXER_EMAIL_FROM_EMAIL: noreply@example.com
FLUXER_EMAIL_FROM_NAME: Fluxer
FLUXER_EMAIL_APP_BASE_URL: https://chat.example.com
FLUXER_EMAIL_SMTP_HOST: "10.0.0.60"
FLUXER_EMAIL_SMTP_PORT: "2526"
FLUXER_EMAIL_SMTP_USERNAME: ""
FLUXER_EMAIL_SMTP_PASSWORD: *smtp-password
FLUXER_EMAIL_SMTP_SECURE: "false"
FLUXER_SMS_ENABLED: "false"
# Optional services
FLUXER_CAPTCHA_ENABLED: "false"
FLUXER_CAPTCHA_PROVIDER: none
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ""
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ""
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ""
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ""
FLUXER_STRIPE_ENABLED: "false"
FLUXER_NCMEC_ENABLED: "false"
FLUXER_CLAMAV_ENABLED: "false"
FLUXER_DISCOVERY_ENABLED: "true"
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: "false"
# Passkeys
FLUXER_PASSKEY_RP_ID: chat.example.com
FLUXER_PASSKEY_RP_NAME: Fluxer
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: https://chat.example.com
# Internal secrets
FLUXER_SUDO_MODE_SECRET: *sudo-mode-secret
FLUXER_CONNECTION_INITIATION_SECRET: *connection-initiation-secret
FLUXER_VAPID_PUBLIC_KEY: *vapid-public-key
FLUXER_VAPID_PRIVATE_KEY: *vapid-private-key
FLUXER_VAPID_EMAIL: admin@example.com
FLUXER_GATEWAY_RPC_AUTH_TOKEN: *gateway-rpc-auth-token
FLUXER_MEDIA_PROXY_SECRET_KEY: *media-proxy-secret-key
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: *media-proxy-upload-relay-secret
FLUXER_ADMIN_SECRET_KEY_BASE: *admin-secret-key-base
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: *admin-oauth-client-secret
# Internal / public endpoints
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
FLUXER_INTERNAL_GATEWAY_ENDPOINT: http://gateway:8080
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MARKETING_ENDPOINT: https://chat.example.com
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MEDIA_ENDPOINT: https://chat.example.com/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: https://chat.example.com/media
x-fluxer-service: &fluxer-service
restart: unless-stopped
networks:
- fluxer
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
test:
- CMD
- bash
- -c
- >-
exec 3<>/dev/tcp/127.0.0.1/8090 &&
printf 'GET /_health HTTP/1.0\r\n\r\n' >&3 &&
head -n 1 <&3 | grep -q ' 200 '
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
services:
# Generates the upstream-style Caddyfile into a named volume so this remains
# a single Portainer stack definition.
config-init:
image: busybox:1.37
restart: "no"
networks:
- fluxer
volumes:
- fluxer-config:/generated
command:
- /bin/sh
- -c
- |
set -eu
cat > /generated/Caddyfile <<'CADDYEOF'
{
servers {
trusted_proxies static {$$FLUXER_EDGE_TRUSTED_PROXIES:private_ranges}
trusted_proxies_strict
}
}
{$$FLUXER_EDGE_SITE_ADDRESS} {
encode zstd gzip
handle /_health {
respond "OK" 200
}
handle_path /api/* {
reverse_proxy api:8080
}
handle /gateway {
rewrite * /
reverse_proxy gateway:8080
}
handle_path /gateway/* {
reverse_proxy gateway:8080
}
handle_path /media/* {
reverse_proxy media-proxy:8080
}
handle_path /livekit/* {
reverse_proxy livekit:7880
}
handle /admin {
rewrite * /
reverse_proxy admin:8080
}
handle_path /admin/* {
reverse_proxy admin:8080
}
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
handle @staticAssets {
reverse_proxy static-proxy:8080
}
handle /.well-known/fluxer {
reverse_proxy api:8080
}
handle {
reverse_proxy app-proxy:8080
}
}
:8088 {
handle /.well-known/fluxer {
reverse_proxy api:8080
}
}
CADDYEOF
chmod 0444 /generated/Caddyfile
echo "Fluxer edge config generated"
edge:
image: caddy:2.10-alpine
restart: unless-stopped
entrypoint:
- /usr/bin/caddy
command:
- run
- --config
- /etc/caddy/Caddyfile
- --adapter
- caddyfile
networks:
- fluxer
# TLS terminates on your existing reverse proxy.
ports:
- "8080:80"
environment:
FLUXER_EDGE_SITE_ADDRESS: ":80"
# Kept broad to match your existing LAN reverse-proxy topology.
# For stricter IP provenance, replace private_ranges with the proxy's CIDR.
FLUXER_EDGE_TRUSTED_PROXIES: private_ranges
volumes:
- fluxer-config:/etc/caddy:ro
- edge-data:/data
- edge-config:/config
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
interval: 10s
timeout: 5s
retries: 10
depends_on:
config-init: {condition: service_completed_successfully}
api: {condition: service_started}
gateway: {condition: service_healthy}
media-proxy: {condition: service_started}
static-proxy: {condition: service_started}
admin: {condition: service_started}
postgres:
image: postgres:16-alpine
restart: unless-stopped
networks:
- fluxer
# Current upstream PostgreSQL tuning; these are DB settings, not Docker limits.
command: >
postgres
-c max_connections=150
-c shared_buffers=512MB
-c effective_cache_size=2GB
-c work_mem=8MB
-c maintenance_work_mem=256MB
-c autovacuum_work_mem=128MB
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c default_statistics_target=200
-c jit=off
-c min_wal_size=512MB
-c max_wal_size=2GB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c wal_compression=zstd
-c bgwriter_delay=50ms
-c bgwriter_lru_maxpages=1000
-c autovacuum_vacuum_scale_factor=0.05
-c autovacuum_analyze_scale_factor=0.02
-c autovacuum_vacuum_cost_limit=2000
-c track_io_timing=on
-c shared_preload_libraries=pg_stat_statements
shm_size: 256mb
environment:
POSTGRES_DB: fluxer
POSTGRES_USER: fluxer
POSTGRES_PASSWORD: *postgres-password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fluxer -d fluxer"]
interval: 10s
timeout: 5s
retries: 10
valkey:
image: valkey/valkey:8.1-alpine
restart: unless-stopped
networks:
- fluxer
# Persistent, but intentionally no max-memory guardrail.
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "everysec", "--dir", "/data"]
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
nats:
image: nats:2.14-alpine
restart: unless-stopped
networks:
- fluxer
command: ["-js", "-sd", "/data", "-m", "8222"]
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
interval: 10s
timeout: 5s
retries: 10
meilisearch:
image: getmeili/meilisearch:v1.12
restart: unless-stopped
networks:
- fluxer
environment:
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_MASTER_KEY: *meili-master-key
volumes:
- meilisearch-data:/meili_data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7700/health"]
interval: 10s
timeout: 5s
retries: 10
seaweedfs:
image: chrislusf/seaweedfs:4.34
restart: unless-stopped
networks:
- fluxer
command: ["server", "-s3", "-dir=/data"]
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/healthz"]
interval: 10s
timeout: 5s
retries: 20
start_period: 60s
seaweedfs-init:
image: chrislusf/seaweedfs:4.34
networks:
- fluxer
depends_on:
seaweedfs: {condition: service_healthy}
restart: "no"
environment:
FLUXER_S3_ACCESS_KEY: *s3-access-key
FLUXER_S3_SECRET_KEY: *s3-secret-key
FLUXER_S3_BUCKET_CDN: fluxer
FLUXER_S3_BUCKET_UPLOADS: fluxer-uploads
FLUXER_S3_BUCKET_DOWNLOADS: fluxer-downloads
FLUXER_S3_BUCKET_REPORTS: fluxer-reports
FLUXER_S3_BUCKET_HARVESTS: fluxer-harvests
entrypoint:
- /bin/sh
- -c
- >
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
missing="$$buckets";
for attempt in $$(seq 1 60); do
if ! nc -z seaweedfs 9333 2>/dev/null; then
sleep 2;
continue;
fi;
listed=$$(echo "s3.bucket.list" | timeout 10 weed shell -master=seaweedfs:9333 2>&1);
missing="";
for b in $$buckets; do
echo "$$listed" | grep -q "^[[:space:]]*$$b[[:space:]]" || missing="$${missing:+$$missing }$$b";
done;
if [ -z "$$missing" ]; then
if ! echo "s3.configure -user=fluxer -access_key=$$FLUXER_S3_ACCESS_KEY -secret_key=$$FLUXER_S3_SECRET_KEY -actions=Admin,Read,Write,List,Tagging -apply" | timeout 10 weed shell -master=seaweedfs:9333 >/dev/null 2>&1; then
echo "seaweedfs-init could not configure the S3 identity" >&2;
exit 1;
fi;
echo "buckets ready";
exit 0;
fi;
for b in $$missing; do
echo "s3.bucket.create -name $$b" | timeout 10 weed shell -master=seaweedfs:9333 >/dev/null 2>&1;
done;
sleep 2;
done;
echo "seaweedfs-init could not verify buckets: $$missing" >&2;
exit 1
livekit:
image: livekit/livekit-server:v1.12.0
restart: unless-stopped
networks:
- fluxer
environment:
# This line intentionally duplicates the anchored key/secret because Compose
# cannot concatenate aliases inside a scalar.
LIVEKIT_KEYS: "CHANGE_ME_LIVEKIT_API_KEY: CHANGE_ME_LIVEKIT_API_SECRET"
LIVEKIT_CONFIG: |
port: 7880
log_level: info
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: true
# Optional for mixed LAN/WAN clients that should avoid NAT reflection:
# advertise_internal_ip: true
# Optional: set to an explicit node IP only when you understand the ICE implications.
node_ip: ""
stun_servers:
- stun.l.google.com:19302
- stun1.l.google.com:19302
webhook:
api_key: CHANGE_ME_LIVEKIT_API_KEY
urls:
- http://api:8080/webhooks/livekit
ports:
- "7881:7881/tcp"
- "7882:7882/udp"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7880/"]
interval: 10s
timeout: 5s
retries: 10
api:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-api:v1
environment:
<<: *fluxer-env
FLUXER_API_PORT: "8080"
NODE_OPTIONS: --enable-source-maps
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
healthcheck:
test:
- CMD-SHELL
- >-
node -e "fetch('http://127.0.0.1:8080/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
start_interval: 1s
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
nats: {condition: service_healthy}
meilisearch: {condition: service_healthy}
seaweedfs-init: {condition: service_completed_successfully}
gifs: {condition: service_healthy}
gifs-shard: {condition: service_healthy}
snowflakes: {condition: service_healthy}
snowflakes-shard: {condition: service_healthy}
messages: {condition: service_healthy}
messages-shard: {condition: service_healthy}
users: {condition: service_healthy}
users-shard: {condition: service_healthy}
worker:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-api:v1
working_dir: /usr/src/app/fluxer_api
command:
- sh
- -c
- >-
if [ -f dist/WorkerEntrypoint.js ];
then exec node dist/WorkerEntrypoint.js;
else exec ./node_modules/.bin/tsx src/WorkerEntrypoint.ts;
fi
environment:
<<: *fluxer-env
NODE_OPTIONS: --enable-source-maps
FLUXER_API_WORKER_MODE: all_lanes
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
healthcheck:
test:
- CMD
- node
- -e
- >-
const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;
if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}
interval: 10s
timeout: 5s
retries: 3
start_period: 90s
start_interval: 1s
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
nats: {condition: service_healthy}
seaweedfs-init: {condition: service_completed_successfully}
snowflakes-shard: {condition: service_healthy}
messages-shard: {condition: service_healthy}
users-shard: {condition: service_healthy}
gateway:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-gateway:v1
environment:
<<: *fluxer-env
FLUXER_GATEWAY_PORT: "8080"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: https://chat.example.com/media
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: https://chat.example.com
FLUXER_GATEWAY_LOGGER_LEVEL: info
FLUXER_ERLANG_COOKIE: *erlang-cookie
FLUXER_ERLANG_SCHEDULERS_MIN: "2"
FLUXER_ERLANG_SCHEDULERS_MAX: "16"
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/_health/ready"]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
depends_on:
nats: {condition: service_healthy}
valkey: {condition: service_healthy}
media-proxy:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-media-proxy:v1
environment:
<<: *fluxer-env
FLUXER_MEDIA_PROXY_HOST: 0.0.0.0
FLUXER_MEDIA_PROXY_PORT: "8080"
FLUXER_MEDIA_PROXY_MODE: upload
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: https://chat.example.com/media
FLUXER_S3_READ_SIGNED: "true"
depends_on:
seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_healthy}
static-proxy:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-static:v1
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/avatars/0.png"]
interval: 10s
timeout: 5s
retries: 10
app-proxy:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-app-proxy-self-hosted:v1
environment:
FLUXER_APP_PROXY_HOST: 0.0.0.0
FLUXER_APP_PROXY_PORT: "8080"
FLUXER_BASE_DOMAIN: chat.example.com
FLUXER_PUBLIC_SCHEME: https
FLUXER_PUBLIC_PORT: "443"
FLUXER_PUBLIC_ORIGIN: https://chat.example.com
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://chat.example.com/api
FLUXER_CSP_EXTRA_DEFAULT_SRC: ""
FLUXER_CSP_EXTRA_CONNECT_SRC: ""
FLUXER_CSP_EXTRA_IMG_SRC: ""
FLUXER_CSP_EXTRA_MEDIA_SRC: ""
FLUXER_CSP_EXTRA_FONT_SRC: ""
FLUXER_CSP_EXTRA_SCRIPT_SRC: ""
FLUXER_CSP_EXTRA_STYLE_SRC: ""
FLUXER_CSP_EXTRA_FRAME_SRC: ""
FLUXER_CSP_EXTRA_WORKER_SRC: ""
FLUXER_CSP_EXTRA_MANIFEST_SRC: ""
FLUXER_CSP_REPORT_URI: ""
depends_on:
api: {condition: service_healthy}
edge: {condition: service_healthy}
snowflakes:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-snowflakes:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: snowflakes
FLUXER_SVC_MODE: router
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
snowflakes-shard:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-snowflakes:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: snowflakes
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
users:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-users:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: ""
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
users-shard:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-users:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: ""
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
postgres: {condition: service_healthy}
gifs:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-gifs:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: https://chat.example.com/media
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
gifs-shard:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-gifs:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: https://chat.example.com/media
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
messages:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-messages:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: ""
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
messages-shard:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-messages:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: ""
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
postgres: {condition: service_healthy}
unfurl:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-unfurl:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: unfurl
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: https://chat.example.com/media
FLUXER_STATIC_CDN_ENDPOINT: https://chat.example.com
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
unfurl-shard:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-unfurl:v1
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: unfurl
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: https://chat.example.com/media
FLUXER_STATIC_CDN_ENDPOINT: https://chat.example.com
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
admin:
<<: *fluxer-service
image: ghcr.io/fluxerapp/fluxer-admin:v1
environment:
<<: *fluxer-env
FLUXER_ADMIN_HOST: 0.0.0.0
FLUXER_ADMIN_PORT: "8080"
FLUXER_ADMIN_BASE_PATH: /admin
FLUXER_API_ENDPOINT: http://api:8080
FLUXER_ADMIN_ENDPOINT: https://chat.example.com/admin
FLUXER_APP_ENDPOINT: https://chat.example.com
FLUXER_MEDIA_ENDPOINT: https://chat.example.com/media
FLUXER_STATIC_CDN_ENDPOINT: https://chat.example.com
FLUXER_ADMIN_OAUTH_REDIRECT_URI: https://chat.example.com/admin/oauth2_callback
healthcheck:
test:
- CMD
- bash
- -c
- >-
exec 3<>/dev/tcp/127.0.0.1/8080 &&
printf 'GET /_health HTTP/1.0\r\n\r\n' >&3 &&
head -n 1 <&3 | grep -q ' 200 '
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
depends_on:
api: {condition: service_healthy}
networks:
fluxer:
driver: bridge
volumes:
fluxer-config:
edge-data:
edge-config:
postgres-data:
valkey-data:
nats-data:
meilisearch-data:
seaweedfs-data:
References
- Fluxer upstream self-hosting Compose: https://github.com/fluxerapp/fluxer/blob/main/deploy/self-hosting/docker-compose.yml
- Fluxer upstream Caddyfile: https://github.com/fluxerapp/fluxer/blob/main/deploy/self-hosting/Caddyfile
- Fluxer discussion: existing reverse proxies / Nginx: https://github.com/orgs/fluxerapp/discussions/1041
- Fluxer desktop self-hosting issue #1088: https://github.com/fluxerapp/fluxer/issues/1088
- Fluxer TURN documentation gap / tunnel discussion #1126: https://github.com/fluxerapp/fluxer/issues/1126
- Fluxer Canary status / download guidance #1400: https://github.com/fluxerapp/fluxer/issues/1400
- Fluxer Canary download: https://canary.fluxer.app/download
- LiveKit configuration sample: https://github.com/livekit/livekit/blob/master/config-sample.yaml
- Nginx HTTP/2 module: https://nginx.org/en/docs/http/ngx_http_v2_module.html
- Nginx sub_filter module: https://nginx.org/en/docs/http/ngx_http_sub_module.html
- MDN RTCPeerConnection: https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection