Deployment log for Fluxer on fluxer.mydomain.net — a free, open-source Discord alternative (AGPLv3).
Fluxer runs as a monolith Docker Compose stack with 6 containers:
| Container | Image | Purpose |
|---|---|---|
fluxer-server |
Built from source | API, WebSocket gateway (Erlang/OTP), media proxy, admin panel, SPA web app |
fluxer-valkey |
valkey/valkey:8.0.6-alpine |
Redis-compatible cache/session store |
fluxer-meilisearch |
getmeili/meilisearch:v1.14 |
Full-text search engine |
fluxer-livekit |
livekit/livekit-server:v1.9.11 |
Voice/video SFU (WebRTC) |
fluxer-nats-core |
nats:2-alpine |
Pub/sub messaging |
fluxer-nats-jetstream |
nats:2-alpine |
Persistent job queue |
Tech stack: Node.js/TypeScript backend, Erlang/OTP WebSocket gateway, React/Rust-WASM frontend, SQLite database.
- Docker + Docker Compose
- Traefik reverse proxy with TLS (certresolver or Cloudflare-terminated)
- External
proxyDocker network - Two DNS records pointing to your server
- SMTP relay accessible on the
proxynetwork (optional, for email)
TL;DR: Keep Cloudflare proxy enabled (orange cloud) for both fluxer and lk records. Do NOT set DNS-only (grey cloud) unless you have your own TLS certificates.
If your Traefik setup relies on Cloudflare for TLS termination (SSL mode "Full" with Cloudflare's edge cert), then switching to DNS-only (grey cloud) will break HTTPS. Clients will receive Traefik's default self-signed certificate, causing browser security errors.
This is the case when acme.json contains no certificates and Traefik uses its default cert — Cloudflare's "Full" SSL mode accepts any origin cert (including self-signed), so it works transparently when proxied.
If you see Content-Security-Policy errors in the browser console, they are not caused by Cloudflare proxy. Common CSP errors and their real causes:
| Error | Real Cause | Fix |
|---|---|---|
script-src-elem blocking inline script from single-file-extension-frames.js |
Browser extension (SingleFile, etc.) | Not a Fluxer issue — disable the extension or ignore |
style-src-elem blocking fluxerstatic.com/fonts/ibm-plex.css |
Server CSP doesn't include fluxerstatic.com |
See Gotcha #11 below |
img-src blocking fluxerstatic.com/web/*.png |
Server CSP doesn't include fluxerstatic.com |
See Gotcha #11 below |
connect-src blocking chat.example.com/.well-known/fluxer |
Frontend built with wrong domain | See Gotcha #12 below |
If you use Cloudflare proxy, ensure these settings in the Cloudflare dashboard:
- SSL/TLS mode: Full (not Flexible, not Full Strict)
- Speed > Optimization > Auto Minify: Disable JavaScript minification (can break hashed assets)
- Speed > Optimization > Rocket Loader: OFF (injects scripts that may conflict with CSP nonces)
- Scrape Shield > Email Address Obfuscation: OFF (injects inline scripts)
Cloudflare proxy handles HTTP/WebSocket signaling for LiveKit (lk.yourdomain.com). The actual voice/video media transport (RTP) uses direct UDP/TCP connections on ports 7881, 3478, and 50000-50100, which bypass DNS entirely — clients connect to the server's IP directly via ICE/STUN negotiation. Cloudflare proxy does not interfere with this.
mkdir -p /srv/fluxer
cd /srv/fluxer
git clone https://github.com/fluxerapp/fluxer.git
cd fluxer
git checkout refactor # The self-hosting/monolith branchGotcha: No public Docker image. The GHCR image at
ghcr.io/fluxerapp/fluxer-server:stablerequires authentication (private registry). You must build from source.
Generate hex secrets for all config values:
# 64-char hex strings (32 bytes)
openssl rand -hex 32 # Repeat for each secret below
# 16-char hex string for LiveKit API key
openssl rand -hex 8Create /srv/fluxer/.env:
MEILI_MASTER_KEY=<64-char hex>
FLUXER_SERVER_IMAGE=fluxer-server:localchmod 600 /srv/fluxer/.envKey settings to get right:
chmod 600 /srv/fluxer/config/config.jsonport: 7880
keys:
'<LIVEKIT_API_KEY>': '<LIVEKIT_API_SECRET>'
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: true
turn:
enabled: true
udp_port: 3478
room:
auto_create: true
max_participants: 100
empty_timeout: 300
webhook:
api_key: '<LIVEKIT_API_KEY>' # CRITICAL - see Gotcha #6
urls:
- "http://fluxer-server:8080/api/webhooks/livekit"The upstream fluxer_server/Dockerfile requires several modifications to build successfully. Apply these changes in the source repo before building:
The deps stage COPY list must match the actual packages in the monorepo. The upstream Dockerfile may reference packages/app/ which doesn't exist, and may be missing newer packages.
Compare ls packages/ with the COPY lines and update accordingly. At time of writing, there are ~47 packages.
The fluxer_app frontend requires Rust + wasm-pack for WebAssembly compilation. Add to the app-build stage:
FROM deps AS app-build
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.93.0 --target wasm32-unknown-unknown
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install wasm-packGotcha #1: Missing
ca-certificates. The slim Debian base image doesn't include CA certificates. Without it,curlto download rustup fails with SSL errors.
The .dockerignore excludes files needed for the build:
# Comment out or remove these lines:
# /fluxer_app/src/data/emojis.json
# /fluxer_app/src/locales/*/messages.js
# Add exceptions for build scripts (the **/build pattern catches them):
!fluxer_app/scripts/build
!fluxer_app/scripts/build/**
Gotcha #2:
.dockerignore**/buildpattern. This glob catchesfluxer_app/scripts/build/rspack/lingui.mjswhich is needed at build time. The exclusion of locale files and emojis.json also breaks TypeScript compilation.
The frontend build needs FLUXER_CONFIG set (rspack reads it to derive API endpoints) and Lingui locale compilation. The BASE_DOMAIN build arg patches the template's placeholder domain (chat.example.com) with your actual domain:
COPY config/config.production.template.json /tmp/fluxer-build-config.json
ARG BASE_DOMAIN="chat.example.com"
RUN sed -i "s/chat\.example\.com/${BASE_DOMAIN}/g" /tmp/fluxer-build-config.json
ARG FLUXER_CDN_ENDPOINT=""
ENV FLUXER_CONFIG=/tmp/fluxer-build-config.json
ENV FLUXER_CDN_ENDPOINT=${FLUXER_CDN_ENDPOINT}
RUN cd fluxer_app && pnpm lingui:compile && pnpm buildGotcha #3:
FLUXER_CONFIG must be set. The rspack config imports the Fluxer config to derive API endpoint URLs for the frontend bundle. Without it, the build crashes immediately.
In fluxer_app/rspack.config.mjs, the CDN endpoint defaults to https://fluxerstatic.com:
// BEFORE (broken for self-hosting):
const CDN_ENDPOINT = process.env.FLUXER_CDN_ENDPOINT || 'https://fluxerstatic.com';
// AFTER (respects empty string):
const CDN_ENDPOINT = 'FLUXER_CDN_ENDPOINT' in process.env ? process.env.FLUXER_CDN_ENDPOINT : 'https://fluxerstatic.com';Gotcha #4: JavaScript
||treats empty string as falsy. SettingFLUXER_CDN_ENDPOINT=""still falls through to the CDN URL. Must useinoperator or??with explicitundefinedcheck. Without this fix, all JS/CSS bundles point tofluxerstatic.cominstead of being served locally.
# BEFORE (broken - root workspace has no start script):
ENTRYPOINT ["pnpm", "start"]
# AFTER:
ENTRYPOINT ["pnpm", "--filter", "fluxer_server", "start"]The HTML template (fluxer_app/index.html) has hardcoded references to fluxerstatic.com for IBM Plex fonts, favicon, and apple-touch-icon. However, the monolith server's Content-Security-Policy only allows 'self', blocking these external resources.
In fluxer_server/src/ServiceInitializer.tsx, find the cspDirectives object in createAppServerInitializer and add https://fluxerstatic.com to styleSrc, imgSrc, and fontSrc:
cspDirectives: {
// ...
styleSrc: ["'self'", "'unsafe-inline'", 'https://fluxerstatic.com'],
imgSrc: ["'self'", 'data:', 'blob:', publicUrlHost, mediaUrlHost, 'https://fluxerstatic.com'],
fontSrc: ["'self'", 'https://fluxerstatic.com'],
// ...
},Gotcha #11: Monolith CSP blocks
fluxerstatic.com. The HTML template loads IBM Plex fonts and favicon from the publicfluxerstatic.comCDN, but the monolith server's CSP only allows'self'. Without this fix, fonts don't load and the browser console shows CSP violation errors for styles, images, and fonts.
cd /srv/fluxer/fluxer
docker build \
-t fluxer-server:local \
--build-arg BASE_DOMAIN="fluxer.yourdomain.com" \
--build-arg FLUXER_CDN_ENDPOINT="" \
--build-arg INCLUDE_NSFW_ML=true \
-f fluxer_server/Dockerfile .Build takes ~20-30 minutes on first run (downloads Rust toolchain, compiles WASM, builds Erlang gateway). Subsequent builds with cached layers are much faster.
BASE_DOMAIN— your Fluxer domain. Baked into the frontend JS for API endpoint discovery. Must matchdomain.base_domainin config.json.FLUXER_CDN_ENDPOINT=""— empty string for self-hosting (assets served from same origin). Without this, all JS/CSS loads fromfluxerstatic.com.INCLUDE_NSFW_ML=true— copies the ONNX model for NSFW image detection into the image.
Key points for the compose file:
fluxer-serverneedsdepends_onwith health checks forfluxer-valkeyandfluxer-meilisearch- Config mounted read-only:
./config:/usr/src/app/config:ro - Data volume for SQLite + file storage:
fluxer-data:/usr/src/app/data - LiveKit needs host ports:
7881:7881/tcp,3478:3478/udp,50000-50100:50000-50100/udp - Both
fluxer-serverandfluxer-livekitneed Traefik labels on theproxynetwork - All internal services on a private
fluxer-internalnetwork
Gotcha #5:
static_dirmust be in config.json, not just an env var. The server readsConfig.services.server.static_dirfrom the JSON config file, not fromFLUXER_SERVER_STATIC_DIRenv var. Without it, the health check showsapp: disabledand the SPA doesn't serve.
Gotcha #6: LiveKit webhook requires
api_keyfield. Thewebhooksection inlivekit.yamlmust includeapi_keyalongsideurls. Without it, LiveKit enters a restart loop withapi_key is required to use webhooks.
Gotcha #7: NATS is required, not optional. The server uses NATS JetStream for its job queue (cron tasks, background processing). Without both
fluxer-nats-coreandfluxer-nats-jetstreamcontainers, the server fatally crashes withCONNECTION_REFUSEDon startup. Deploy both as simplenats:2-alpinecontainers — core on port 4222, JetStream on port 4223 with--jetstream --store_dir /data.
sudo ufw allow 7881/tcp comment 'Fluxer LiveKit ICE TCP'
sudo ufw allow 3478/udp comment 'Fluxer LiveKit TURN/STUN'
sudo ufw allow 50000:50100/udp comment 'Fluxer LiveKit RTP media'Note: Docker-published ports bypass UFW, but documenting them keeps ufw status accurate.
Create two Cloudflare A records pointing to your server IP:
| Name | Type | Value | Proxy |
|---|---|---|---|
fluxer |
A | <server-ip> |
Proxied (orange cloud) |
lk |
A | <server-ip> |
Proxied (orange cloud) |
Keep both records Cloudflare-proxied (orange cloud). See the "Cloudflare DNS and TLS" section above for why DNS-only (grey cloud) breaks HTTPS when Traefik relies on Cloudflare for TLS termination.
LiveKit voice/video media transport (UDP/TCP on ports 7881, 3478, 50000-50100) bypasses DNS entirely — clients connect to the server IP directly via ICE/STUN, so Cloudflare proxy does not interfere.
cd /srv/fluxer
docker compose up -d# All containers running
docker ps --filter "name=fluxer"
# Health check (all services should be "healthy")
curl -s https://fluxer.yourdomain.com/_health | jq
# Web app loads
curl -sI https://fluxer.yourdomain.com/
# LiveKit signaling responds
curl -sI https://lk.yourdomain.com/Expected health response:
{
"status": "healthy",
"services": {
"kv": { "status": "healthy" },
"s3": { "status": "healthy" },
"jetstream": { "status": "healthy" },
"mediaProxy": { "status": "healthy" },
"admin": { "status": "healthy" },
"api": { "status": "healthy" },
"app": { "status": "healthy" }
}
}Then open https://fluxer.yourdomain.com in a browser and register the first user account.
| # | Issue | Symptom | Fix |
|---|---|---|---|
| 1 | Missing ca-certificates in build |
SSL errors downloading rustup | Add ca-certificates to apt-get install in app-build stage |
| 2 | .dockerignore too aggressive |
TypeScript errors (missing locales, emojis, build scripts) | Comment out exclusions, add !fluxer_app/scripts/build exception |
| 3 | FLUXER_CONFIG not set during build |
FLUXER_CONFIG must be set crash |
Copy production template and set env var in Dockerfile |
| 4 | || treats "" as falsy in JS |
All assets point to fluxerstatic.com CDN |
Use in operator check in rspack.config.mjs |
| 5 | static_dir must be in config JSON |
Health shows app: disabled, no SPA |
Add "static_dir": "/usr/src/app/assets" to services.server in config.json |
| 6 | LiveKit webhook needs api_key |
LiveKit restart loop | Add api_key field to webhook section in livekit.yaml |
| 7 | NATS is a hard dependency | Fatal CONNECTION_REFUSED on startup |
Deploy fluxer-nats-core and fluxer-nats-jetstream containers |
| 8 | Dockerfile package list outdated | Build fails on missing package.json files |
Update COPY list to match actual packages in monorepo |
| 9 | No pnpm start in root workspace |
Missing script: start |
Change ENTRYPOINT to pnpm --filter fluxer_server start |
| 10 | GHCR image is private | pull access denied |
Build from source using the refactor branch |
| 11 | Monolith CSP blocks fluxerstatic.com |
Fonts/icons don't load, CSP violations in console | Add https://fluxerstatic.com to styleSrc, imgSrc, fontSrc in ServiceInitializer.tsx |
| 12 | Build config has chat.example.com |
App tries to connect to wrong domain, connect-src CSP error |
Pass --build-arg BASE_DOMAIN=yourdomain and sed-replace in Dockerfile |
| 13 | Cloudflare DNS-only breaks HTTPS | Browser shows invalid/self-signed cert error | Keep Cloudflare proxy enabled (orange cloud); Traefik has no real certs in acme.json |
| 14 | sqlite_path must be absolute |
DB created in container filesystem, lost on recreate | Use "/usr/src/app/data/fluxer.db" (absolute), not "./data/fluxer.db" (relative resolves from fluxer_server/) |
| 15 | Admin panel missing build:css |
/admin/static/app.css returns 404, admin panel unstyled |
Add RUN pnpm --filter @fluxer/admin build:css to Dockerfile after the marketing CSS build |
| 16 | SSO callback not in standalone routes | SSO login loops back to /login instead of completing |
Add pathname.startsWith('/auth/sso/') to isStandaloneRoute in RootComponent.tsx |
| 17 | URLSearchParams body becomes "{}" |
OIDC token exchange fails with "grant_type missing" 400 | Add .toString() to the URLSearchParams in SsoService.exchangeCode() |
| 18 | SSO client secret not loaded | Token exchange sends no secret, "empty client secret" 400 | Pass {includeSecret: true} to getSsoConfig() in SsoService.getResolvedConfig() |
| 19 | SSO timeout masks actual error | Real error replaced by "SSO sign-in timed out" after 30s | Add clearTimeout(timeoutId) in error/success paths of SsoCallbackPage.tsx |
Fluxer supports SSO login via any OIDC provider (e.g., Zitadel, Keycloak). Configuration is done through the admin panel at /admin under instance settings — set the issuer URL, client ID, and client secret. The remaining OIDC endpoints (authorization, token, JWKS, userinfo) are auto-discovered from the issuer's /.well-known/openid-configuration.
Three source code bugs must be fixed before SSO will work:
The RootComponent maintains a list of routes that render without authentication. The SSO callback path /auth/sso/callback is missing, so unauthenticated users returning from the OIDC provider get redirected back to /login in an infinite loop.
In fluxer_app/src/router/components/RootComponent.tsx, add /auth/sso/ to the isStandaloneRoute check:
pathname.startsWith(Routes.CONNECTION_CALLBACK) ||
pathname.startsWith('/auth/sso/') || // ADD THIS LINE
pathname === '/__notfound' ||The SsoService.exchangeCode() method passes a URLSearchParams object as the request body. However, FetchUtils.resolveRequestBody() doesn't handle URLSearchParams — it falls through to JSON.stringify(), which serializes it as "{}" (empty object). The OIDC provider receives Content-Type: application/x-www-form-urlencoded with an empty body and rejects it with "grant_type missing".
In packages/api/src/auth/services/SsoService.tsx, add .toString() to convert the URLSearchParams to a proper URL-encoded string:
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId ?? '',
code_verifier: codeVerifier,
}).toString(); // ADD .toString() — without it, JSON.stringify produces "{}"The SsoService.getResolvedConfig() calls getSsoConfig() without {includeSecret: true}, so the client secret is always undefined. The token exchange sends no Authorization header, and the OIDC provider rejects with "empty client secret".
In packages/api/src/auth/services/SsoService.tsx, in getResolvedConfig():
// BEFORE:
const stored = await this.instanceConfigRepository.getSsoConfig();
// AFTER:
const stored = await this.instanceConfigRepository.getSsoConfig({includeSecret: true});The SsoCallbackPage sets a 30-second timeout, but only clears it in the React cleanup function. If the SSO complete request fails quickly (e.g., 400 error), the catch block shows the real error message, but the still-pending timeout fires 30 seconds later and overwrites it with "SSO sign-in timed out."
In fluxer_app/src/components/pages/SsoCallbackPage.tsx, add clearTimeout(timeoutId) in all early-return, success, and error paths within the async IIFE.
Fluxer has a concept of "unclaimed" accounts (preview/demo users who haven't set a password). These accounts are restricted: no profile updates, guild invites force-disabled, no messages, no voice, no friend requests, no DMs, no reactions, etc. The check is User.isUnclaimedAccount() which returns passwordHash === null && !isBot.
Problem: SSO users are created with password_hash: null (in SsoService.provisionUserFromClaims()), so they're incorrectly classified as unclaimed. This blocks ~15 features for SSO users, including profile updates ("Unclaimed Accounts can only set email via token") and guild invite toggling.
Fix: In packages/api/src/models/User.tsx, update isUnclaimedAccount() to exclude SSO users (who get sso and sso:{providerId} traits at provisioning time):
isUnclaimedAccount(): boolean {
return this.passwordHash === null && !this.isBot && !this._traits.has('sso');
}This single change fixes all unclaimed restrictions across the entire codebase at once, since every check site calls isUnclaimedAccount().
The first user to register on a fresh Fluxer instance is automatically granted wildcard admin ACLs (*). This means the first registered account can access the admin panel at /admin with full permissions.
If you need to grant admin access to other users after the initial setup, you can do so via SQLite:
# Find the user's ID
docker exec fluxer-server sqlite3 /usr/src/app/data/fluxer.db \
"SELECT id, username FROM users WHERE username = 'targetuser';"
# Grant wildcard admin ACL
docker exec fluxer-server sqlite3 /usr/src/app/data/fluxer.db \
"INSERT INTO admin_acls (user_id, permission) VALUES ('<user-id>', '*');"The admin panel is accessible at https://fluxer.yourdomain.com/admin.
| Container | RAM | Notes |
|---|---|---|
| fluxer-server | ~300-500MB | Includes Node.js + embedded Erlang gateway |
| fluxer-valkey | ~50-100MB | Grows with cached data |
| fluxer-meilisearch | ~100-200MB | Scales with indexed messages |
| fluxer-livekit | ~50-100MB idle | Scales with active voice sessions |
| fluxer-nats-core | ~20-30MB | Lightweight pub/sub |
| fluxer-nats-jetstream | ~30-50MB | Grows with queue data |
| Total | ~550-1000MB |
{ "env": "production", "domain": { "base_domain": "fluxer.yourdomain.com", "public_scheme": "https", "public_port": 443 }, "database": { "backend": "sqlite", "sqlite_path": "/usr/src/app/data/fluxer.db" // CRITICAL - must be absolute, see Gotcha #14 }, "internal": { "kv": "redis://fluxer-valkey:6379/0", "kv_mode": "standalone" }, "s3": { "access_key_id": "fluxer-local", "secret_access_key": "fluxer-local-secret", "endpoint": "http://127.0.0.1:8080/s3" // Built-in local S3 }, "instance": { "self_hosted": true, "deployment_mode": "monolith" }, "services": { "server": { "port": 8080, "host": "0.0.0.0", "static_dir": "/usr/src/app/assets" // CRITICAL - see Gotcha #5 }, "gateway": { "port": 8082 }, "nats": { "core_url": "nats://fluxer-nats-core:4222", "jetstream_url": "nats://fluxer-nats-jetstream:4223", "auth_token": "" } // ... media_proxy, admin, marketing with their secrets }, "integrations": { "search": { "engine": "meilisearch", "url": "http://fluxer-meilisearch:7700", "api_key": "<MEILI_MASTER_KEY>" }, "voice": { "enabled": true, "api_key": "<LIVEKIT_API_KEY>", "api_secret": "<LIVEKIT_API_SECRET>", "url": "wss://lk.yourdomain.com", "webhook_url": "http://fluxer-server:8080/api/webhooks/livekit" } } }