I've been running a self-hosted Fluxer instance for about a week now, built from the
refactor branch. (Commit https://github.com/fluxerapp/fluxer/commit/848269a4d4df7349acfc861ff926b17fe4c4a548 at the time I post this, edits will likely follow.) The self-hosting docs are still TBD, so I wanted to share what I found in case others go down this path. This covers build issues, runtime bugs (some of which likely affect the upstream instance too), and SSO integration.
>[!NOTE]
>The root causes I describe in these posts are my assumptions, they are more pointers for maintainers than well thought out fixes that could be submitted as PRs. I don't feel like I know enough of the architecture for that. Think of these as: "I had an issue, this is what I changed to fix it."
Setup: Source build from refactor, behind Traefik reverse proxy, with Valkey, NATS, Meilisearch, and LiveKit. SQLite for the database.
I'll post each issue as a separate reply below so they have their own threads. Here's the summary:
Build/Dockerfile issues
- Dockerfile missing 16+ workspace
package.jsonCOPYs —pnpm installfails .dockerignoreexcludes files needed at build time —**/buildglob andemojis.json- No wasm32 target for Rust — apt-installed rustc doesn't include it,
wasm-packfails - ENTRYPOINT points to root workspace — no
startscript there rspack.config.mjshardcodes CDNpublicPath— self-hosted builds must serve bundles from origin- CSP directives missing
static_cdn_domain— emoji/fonts/icons blocked - Admin CSS not built — missing build step in Dockerfile
tsgo --noEmitfails — locale modules don't exist untillingui:compileruns
Runtime bugs (likely upstream too)
- Voice states missing from READY payload —
guild_data.erlreads from guild process (always empty) instead of voice server process - LiveKit webhooks not configured in
livekit.example.yaml— join/leave events never reach the server VoiceReconciliationWorkeris dead code — never instantiated
SSO/OIDC issues
URLSearchParamsbody serialized as'{}'— token exchange sends empty bodyclient_secretmissing from token exchange —getSsoConfig()omits it by default- Basic auth incompatible with some IdPs — Pocket ID ignores it when
client_idis in body - SSO users treated as "unclaimed" — no password = unclaimed in upstream logic
- SSO callback route not in auth guard allowlist — redirects to
/loginbefore processing
22 comments
Comment by @mgabor3141
1: Dockerfile build fixes
The Dockerfile onrefactorneeds several fixes to produce a working build: Missing workspace package.json COPYs — 16 packages are missing from the deps stage, causingpnpm installto fail:.dockerignoreexcludes build-time files:
No wasm32 target: The apt-installed**/buildexcludesfluxer_app/scripts/build/(rspack config). Fix: add!fluxer_app/scripts/build/fluxer_app/src/data/emojis.jsonis explicitly ignored but needed at build time. Fix: remove that linerustcdoesn't includewasm32-unknown-unknown, sowasm-packfails. Fix: install viarustupinstead:ENTRYPOINT ["pnpm", "start"]targets the root workspace which has nostartscript. Fix:tsgo --noEmitfails at build time because lingui locale.mjsfiles don't exist yet. Fix: removetsgo --noEmit &&fromfluxer_app/package.jsonbuild script.FLUXER_CONFIGnot available in app-build stage: rspack readsconfig.jsonto derive endpoint URLs. Inject a minimal build-time config via build arg:Comment by @mgabor3141
2: Self-hosted asset serving and CSP
rspack.config.mjssetspublicPathto${CDN_ENDPOINT}/in production. Since the CDN hosts the upstream builds, self-hosted instances must serve JS/CSS bundles from their own origin. Fix inrspack.config.mjs:fluxerstatic.com— they're not instance-specific. But the CSP directives inServiceInitializer.tsxdon't includestatic_cdn_domain, so browsers block them. Fix influxer_server/src/ServiceInitializer.tsx:staticCdnHosttoimgSrc,styleSrc, andfontSrcarrays.Comment by @mgabor3141
3: Voice states missing from READY payload (likely upstream bug)
Symptom: After connecting (or refreshing the page), the channel sidebar shows 0 users in voice channels. You only see who's in a voice channel after you join it yourself. The client logs confirm:Initialized voice states from connection open {guildCount: 2, totalVoiceStates: 0}. Root cause:guild_voice_server.erlis a separate process that holds the authoritative voice state. It's always started for every guild (guild.erl:76). All voice mutations (join, leave, confirm) go throughresolve_voice_pid()which routes to this process. Butguild_data:get_guild_state/2— which builds the READY payload — runs inside the guild process and reads voice states from its own state:voice_statesmap is always empty because all mutations go to the voice server process. The fallback handler inguild_voice_handler.erlis effectively dead code sinceresolve_voice_pidalways finds the voice server via ETS. This is likely an upstream bug too — the voice server process was presumably split out from the guild process to reduce contention, butget_guild_statewas never updated to fetch from the new location. Fix influxer_gateway/src/guild/guild_data.erl:{get_voice_states_list}handler (guild_voice_server.erl:203), so this just wires it up.Comment by @mgabor3141
4: LiveKit webhooks not configured
Symptom: Users appear stuck in voice channels after leaving. Rejoining shows duplicate entries. The Fluxer server logs show zero webhook events. Root cause:config/livekit.example.yamlhas nowebhooksection. Without it, LiveKit never sendsparticipant_joined/participant_left/room_finishedevents to the Fluxer server. The server has a fullLiveKitWebhookServicethat handles these events and updates gateway state, but it never receives anything. Fix — add tolivekit.yaml:api_keymust match one of the keys in thekeys:section. The URL must be reachable from the LiveKit container. Related:VoiceReconciliationWorkerinpackages/api/src/voice/VoiceReconciliationWorker.tsxis designed to be a safety net that periodically cross-references LiveKit participants with gateway voice states and cleans up ghosts. However, it's never imported or instantiated anywhere inWorkerDependencies.tsx— it's dead code. Wiring it up would add resilience against missed webhooks.Comment by @mgabor3141
5: SSO/OIDC token exchange broken (3 compounding bugs)
Setting up SSO with an OIDC provider (tested with Pocket ID) fails at the token exchange step. Three bugs compound: 1.URLSearchParamsbody serialized as'{}'packages/http_client/src/HttpClientRequestInternals.tsx—resolveRequestBody()JSON-stringifies non-string bodies.JSON.stringify(new URLSearchParams(...))produces'{}', so the token exchange POST body is empty.client_secretnot included in token exchangepackages/api/src/auth/services/SsoService.tsxcallsgetSsoConfig()without{ includeSecret: true }, soclientSecretis alwaysundefined.client_idin the POST body andclient_secretviaAuthorization: Basicheader. Some IdPs (e.g. Pocket ID) only parse Basic auth whenclient_idis absent from the body. Since it's present, the Basic auth is ignored entirely. Fix: sendclient_secretin the POST body instead:User.tsxconsiders users withpasswordHash === null && !isBotas unclaimed. SSO-provisioned users have no password but are legitimate. Fix: add&& !this._traits.has('sso')to the condition.RootComponent.tsxredirects unauthenticated users to/loginbefore the callback page at/auth/sso/callbackcan process the authorization code. Fix: add the path to the allowlist alongside the login route.Comment by @mgabor3141
6: Config architecture and gotchas for self-hosters
Erlang gateway can't use env overrides. The Node.js server supportsFLUXER_CONFIG__prefixed env vars for config overrides (double-underscore separated path). The Erlang gateway readsconfig.jsondirectly with no env substitution. Any config value the gateway needs must be in the JSON file — env overrides won't reach it. Practical consequence: if you set a NATS auth token via env vars, the Node.js server authenticates fine but the gateway silently fails to connect. Easiest workaround is to run NATS without auth — it's only on the internal Docker network anyway. Git LFS will break your clone. The repo uses LFS for static assets (fluxer_static/). If you rungit lfs install(even accidentally), it enables the smudge filter globally and every subsequent git operation tries to download from a private LFS store and hangs indefinitely. Clone with:-c filter.lfs.smudge= -c filter.lfs.required=falseto every git command. Related: the upstream Dockerfile has aCOPY fluxer_static/ ...line that copies LFS pointer files (not actual assets). Remove it — static assets (emoji SVGs, fonts, icons) are served fromfluxerstatic.comCDN and aren't instance-specific. SQLite path must be absolute.pnpmchanges the working directory, so a relativesqlite_pathresolves to the wrong location and you get a silent empty database. Use the full container path:*). The admin panel is at/admin. Once SSO is configured and ready, theLocalAuthMiddlewareblocks the/auth/registerendpoint entirely (SSO_REQUIRED), so new users can only be provisioned via your IdP. LiveKit needs special handling. See Reply 7 for details, but the short version: don't use Docker port mappings for the UDP range (it can crash your system), use host networking instead, and plan for dynamic IP if you're on a residential connection.Comment by @mgabor3141
7: LiveKit deployment — port mapping crash, host networking, and dynamic IP
Docker port mapping crash with UDP ranges
The upstreamcompose.yamlmaps LiveKit ports like this:50000-50100range creates 101 individual iptables/nftables rules in the Docker proxy. On my system this caused the entire Docker networking stack to hang — all containers lost connectivity and the host became unresponsive. Had to hard reboot. This will hit anyone whose Docker setup uses iptables-based port mapping (the default).Solution: host networking
LiveKit works much better withnetwork_mode: host. It binds directly to the host's interfaces, avoids the port mapping overhead entirely, and also solves NAT hairpinning issues. With Docker's bridge networking, WebRTC clients on the same LAN as the server couldn't connect — the ICE candidates advertised the external IP, but hairpin NAT back through the router failed. Host networking eliminates this since LiveKit sees the real network interfaces and can advertise both internal and external IPs correctly.wss://signaling URL in Fluxer config points to a Traefik route that proxies to127.0.0.1:7880.Dynamic IP and the
node_ipproblemnode_ipin the LiveKit config tells clients which IP to send media to. If you're on a residential connection with a dynamic IP, this value goes stale when your IP changes. LiveKit reads the config once at startup and has no mechanism to detect IP changes. My workaround uses three scripts: Entrypoint — resolves a DDNS hostname to an IP at startup:willfarrell/autohealwatches for unhealthy containers and restarts them. When the healthcheck fails due to IP change, autoheal restarts LiveKit, which re-runs the entrypoint and picks up the new IP.Port forwarding summary
With host networking, forward these on your router to the Fluxer host:50000-50100/udpas the RTP range. With a singleudp_portinstead, LiveKit multiplexes all media over one port. This is fine for a small instance and avoids the port range mapping issue entirely.Comment by @mgabor3141
8: Desktop app for self-hosted instances (+ SSO deep link flow)
I got the desktop app (fluxer_desktop) working with a self-hosted instance, including SSO login via an external OIDC provider with passkey support. The short version: point the desktop app at your instance viasettings.json, fix a few build issues influxer_desktop, and add afluxer://deep link flow so the OIDC callback can get back from the browser to the Electron app. Full writeup with all the details is on issue #458. Branch with all changes:feat/desktop-custom-instance-url-clean.Comment by @yak3d
Comment by @mgabor3141
Comment by @yak3d
Comment by @cootason
Comment by @cootason
Comment by @Mar0xy
Setup Guide
1. Add Service to Compose
Here we will use thebitnamilegacyimage as all the other cassandra images require you to directly have a full on cassandra config to get them to work.cassandra_data:2. Configure cassandra in the Fluxer Config
3. Prepare Cassandra
Start just the cassandra instance and wait for it to be fully booted (takes about 30-40 seconds) then run the following commands:4. Run Migrations
Now we will get to the hard part as the migration script is not included in the docker image you will have to run all migrations yourself one by one in the shell by going through each file influxer_devops/cassandra/migrations5. Start Fluxer
After all this is done fluxer should be able to start without issuesCaveats
Cassandra tends to eat a lot of RAM so make sure you either host it on a separate machine if possible or you have a beefy server otherwise it will Exited with code 137 while fluxer is up and you get the dreaded loading/splash screenBenefits
You no longer have to deal with the KV SQLite file meaning you can actually easily modify the table entries and etc like adding visionary to a user by running this queryRAM usage improvement
OOTB Cassandra takes up as much RAM as it can (in my case it was 8GB) you can limit the usage by adding this into the compose: environments section on the cassandra service:Comment by @mgabor3141
Comment by @Mar0xy
CASSANDRA_CFG_YAML_DISK_ACCESS_MODE: mmap_index_onlyComment by @mgabor3141
9: S3
Symptom: All avatars, banners, and attachments return 404 after a container rebuild. The S3 bucket directories exist on the persistent volume but are empty. Root cause: The S3 service'sdata_dirresolves relative tofluxer_server/, not the project root — uploads silently written to ephemeral container layerdata_dirdefaults to./data/s3(a relative path). Since the entrypoint ispnpm --filter fluxer_server start, pnpm sets the cwd to/usr/src/app/fluxer_server/. This means./data/s3resolves to/usr/src/app/fluxer_server/data/s3/— inside the container's writable layer — instead of/usr/src/app/data/s3/on the bind mount. Uploads succeed and the app works fine, but the files are silently written to the ephemeral container filesystem. They survive restarts but are permanently lost on container rebuild (docker compose up --build, image update, etc.). This is the same class of bug as thesqlite_pathissue — the Dockerfile's ENTRYPOINT usespnpm --filter, which changes the working directory, breaking all relative path defaults. Fix: Set an absolute path forservices.s3.data_dirinconfig.json:services.queue.data_dir(./data/queue), but it appears unused in the self-hosted setup since the queue is backed by NATS JetStream.Comment by @MrRubberDucky
Comment by @Mar0xy
Comment by @MrRubberDucky
Comment by @Mar0xy
Broken Klipy Categories/Gif Picker
There is currently an issue where in thepackages/api/src/klipy/KlipyService.tsxfile it tries to just grab the gif as awebmbut this can fail when thewebmarray is missing or if thewebmarray is missing theurl/dimsvalues causing the gif picker for example to not show the categories. This can be fixed by adding a fallback to make it use thegifarray instead if either of the two things mentioned above are missing on lines 332-335 here is a the return snippet fully for easy implementationComment by @Takalele
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);withconst staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw) || 'https://fluxerstatic.com';(2 times, line 28 and 78) in the file fluxer_app/scripts/build/rspack/static-files.mjs, then rebuild and redeploy the container.