fluxer-self-hosting-nginx-portainer-windows.md
cat fluxer-self-hosting-nginx-portainer-windows.md

Self-Hosting Fluxer Behind Nginx: Portainer, LiveKit, and the Desktop Voice Fix

16 min readby Dan
  • #fluxer
  • #self-hosting
  • #portainer
  • #nginx
  • #livekit
  • #webrtc
  • #homelab
  • #windows

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:

text
iceTransportPolicy: "relay"

while my LiveKit configuration supplied STUN servers and no TURN server. In plain English:

text
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.

text
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:

PurposeExample
Fluxer public namechat.example.com
Fluxer Docker host10.0.0.50
Nginx reverse proxyany host that can reach 10.0.0.50:8080
Optional Stalwart host10.0.0.60
Fluxer edge host port8080
LiveKit ICE/TCP7881/tcp
LiveKit ICE/UDP7882/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:

bash
openssl rand -hex 32

For the media upload relay secret, generate base64:

bash
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:

bash
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:

  1. Open Stacks.
  2. Create a new stack, for example fluxer.
  3. Use the Web editor.
  4. Paste the stack from Appendix B.
  5. Replace chat.example.com, SMTP settings if used, and every CHANGE_ME_* secret.
  6. Deploy the stack.

The key external-facing pieces are intentionally simple:

yaml
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:

yaml
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:

nginx
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:

nginx
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:

bash
nginx -t
systemctl reload nginx

Then verify that the outside connection is actually using HTTP/2:

bash
curl -I --http2 https://chat.example.com

The first line should be an HTTP/2 response.

A couple of useful sanity checks are:

bash
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:

text
TCP 80/443 -> Nginx reverse proxy

Voice/video requires two additional forwards directly to the Fluxer/LiveKit host:

text
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:

text
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:

text
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:

text
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:

text
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:

yaml
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:

text
https://canary.fluxer.app/download

The normal installed executable is currently under:

text
%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:

text
--fluxer-app-url="https://chat.example.com"

So from PowerShell:

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.

powershell
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:

text
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:

text
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:

javascript
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:

text
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
javascript
(() => {
    // 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.

nginx
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 /:

nginx
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:

bash
nginx -t
systemctl reload nginx

In Canary DevTools, the useful confirmation is:

text
[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:

javascript
const STABLE_APP_URL = "https://web.fluxer.app";
const CANARY_APP_URL = "https://web.canary.fluxer.app";

and the selected URL is effectively:

javascript
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:

text
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:

text
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:

javascript
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:

bash
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:

yaml
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.yml and Caddyfile with 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-url workaround may eventually be replaced by a proper instance selector.

Final result

The end state is pleasantly conventional once the moving pieces are separated:

text
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
nginx
# /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
yaml
# 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

exit.sh
cd ..