{"slug": "fluxer-self-hosted-deployment-guide-refactor-branch-20-gotchas-documented", "title": "Fluxer self-hosted deployment guide (refactor branch) — 20 gotchas documented", "summary": "This article provides a deployment guide for Fluxer, a self-hosted, open-source Discord alternative, detailing 20 common issues (\"gotchas\") encountered during setup. Key recommendations include keeping Cloudflare proxy enabled for TLS termination, disabling specific Cloudflare optimizations like Rocket Loader and JavaScript minification to avoid CSP errors, and building the Docker image from source due to a private registry. The guide also specifies critical configuration settings, such as using absolute paths for SQLite and static assets, and notes that while Cloudflare handles WebSocket signaling for LiveKit, direct UDP/TCP media transport bypasses the proxy entirely.", "body_md": "# Fluxer Self-Hosted Deployment Guide\n\nDeployment log for Fluxer on `fluxer.mydomain.net` — a free, open-source Discord alternative (AGPLv3).\n\n## Architecture\n\nFluxer runs as a **monolith** Docker Compose stack with 6 containers:\n\n| Container | Image | Purpose |\n|---|---|---|\n| `fluxer-server` | Built from source | API, WebSocket gateway (Erlang/OTP), media proxy, admin panel, SPA web app |\n| `fluxer-valkey` | `valkey/valkey:8.0.6-alpine` | Redis-compatible cache/session store |\n| `fluxer-meilisearch` | `getmeili/meilisearch:v1.14` | Full-text search engine |\n| `fluxer-livekit` | `livekit/livekit-server:v1.9.11` | Voice/video SFU (WebRTC) |\n| `fluxer-nats-core` | `nats:2-alpine` | Pub/sub messaging |\n| `fluxer-nats-jetstream` | `nats:2-alpine` | Persistent job queue |\n\nTech stack: Node.js/TypeScript backend, Erlang/OTP WebSocket gateway, React/Rust-WASM frontend, SQLite database.\n\n## Prerequisites\n\n- Docker + Docker Compose\n- Traefik reverse proxy with TLS (certresolver or Cloudflare-terminated)\n- External `proxy` Docker network\n- Two DNS records pointing to your server\n- SMTP relay accessible on the `proxy` network (optional, for email)\n\n## Cloudflare DNS and TLS\n\n**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.\n\n### Why Cloudflare proxy is required\n\nIf 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.\n\nThis 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.\n\n### Cloudflare proxy does NOT cause CSP errors\n\nIf you see Content-Security-Policy errors in the browser console, they are **not** caused by Cloudflare proxy. Common CSP errors and their real causes:\n\n| Error | Real Cause | Fix |\n|---|---|---|\n| `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 |\n| `style-src-elem` blocking `fluxerstatic.com/fonts/ibm-plex.css` | Server CSP doesn't include `fluxerstatic.com` | See Gotcha #11 below |\n| `img-src` blocking `fluxerstatic.com/web/*.png` | Server CSP doesn't include `fluxerstatic.com` | See Gotcha #11 below |\n| `connect-src` blocking `chat.example.com/.well-known/fluxer` | Frontend built with wrong domain | See Gotcha #12 below |\n\n### Cloudflare settings to verify\n\nIf you use Cloudflare proxy, ensure these settings in the Cloudflare dashboard:\n\n- **SSL/TLS mode**: Full (not Flexible, not Full Strict)\n- **Speed > Optimization > Auto Minify**: Disable JavaScript minification (can break hashed assets)\n- **Speed > Optimization > Rocket Loader**: OFF (injects scripts that may conflict with CSP nonces)\n- **Scrape Shield > Email Address Obfuscation**: OFF (injects inline scripts)\n\n### LiveKit media transport\n\nCloudflare 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.\n\n## Step 1: Clone the Source\n\n```bash\nmkdir -p /srv/fluxer\ncd /srv/fluxer\ngit clone https://github.com/fluxerapp/fluxer.git\ncd fluxer\ngit checkout refactor  # The self-hosting/monolith branch\n```\n\n> **Gotcha: No public Docker image.** The GHCR image at `ghcr.io/fluxerapp/fluxer-server:stable` requires authentication (private registry). You must build from source.\n\n## Step 2: Generate Secrets\n\nGenerate hex secrets for all config values:\n\n```bash\n# 64-char hex strings (32 bytes)\nopenssl rand -hex 32  # Repeat for each secret below\n\n# 16-char hex string for LiveKit API key\nopenssl rand -hex 8\n```\n\nCreate `/srv/fluxer/.env`:\n\n```env\nMEILI_MASTER_KEY=<64-char hex>\nFLUXER_SERVER_IMAGE=fluxer-server:local\n```\n\n```bash\nchmod 600 /srv/fluxer/.env\n```\n\n## Step 3: Create Config Files\n\n### `/srv/fluxer/config/config.json`\n\nKey settings to get right:\n\n```jsonc\n{\n  \"env\": \"production\",\n  \"domain\": {\n    \"base_domain\": \"fluxer.yourdomain.com\",\n    \"public_scheme\": \"https\",\n    \"public_port\": 443\n  },\n  \"database\": {\n    \"backend\": \"sqlite\",\n    \"sqlite_path\": \"/usr/src/app/data/fluxer.db\"  // CRITICAL - must be absolute, see Gotcha #14\n  },\n  \"internal\": {\n    \"kv\": \"redis://fluxer-valkey:6379/0\",\n    \"kv_mode\": \"standalone\"\n  },\n  \"s3\": {\n    \"access_key_id\": \"fluxer-local\",\n    \"secret_access_key\": \"fluxer-local-secret\",\n    \"endpoint\": \"http://127.0.0.1:8080/s3\"  // Built-in local S3\n  },\n  \"instance\": {\n    \"self_hosted\": true,\n    \"deployment_mode\": \"monolith\"\n  },\n  \"services\": {\n    \"server\": {\n      \"port\": 8080,\n      \"host\": \"0.0.0.0\",\n      \"static_dir\": \"/usr/src/app/assets\"  // CRITICAL - see Gotcha #5\n    },\n    \"gateway\": {\n      \"port\": 8082\n    },\n    \"nats\": {\n      \"core_url\": \"nats://fluxer-nats-core:4222\",\n      \"jetstream_url\": \"nats://fluxer-nats-jetstream:4223\",\n      \"auth_token\": \"\"\n    }\n    // ... media_proxy, admin, marketing with their secrets\n  },\n  \"integrations\": {\n    \"search\": {\n      \"engine\": \"meilisearch\",\n      \"url\": \"http://fluxer-meilisearch:7700\",\n      \"api_key\": \"<MEILI_MASTER_KEY>\"\n    },\n    \"voice\": {\n      \"enabled\": true,\n      \"api_key\": \"<LIVEKIT_API_KEY>\",\n      \"api_secret\": \"<LIVEKIT_API_SECRET>\",\n      \"url\": \"wss://lk.yourdomain.com\",\n      \"webhook_url\": \"http://fluxer-server:8080/api/webhooks/livekit\"\n    }\n  }\n}\n```\n\n```bash\nchmod 600 /srv/fluxer/config/config.json\n```\n\n### `/srv/fluxer/config/livekit.yaml`\n\n```yaml\nport: 7880\n\nkeys:\n  '<LIVEKIT_API_KEY>': '<LIVEKIT_API_SECRET>'\n\nrtc:\n  tcp_port: 7881\n  port_range_start: 50000\n  port_range_end: 50100\n  use_external_ip: true\n\nturn:\n  enabled: true\n  udp_port: 3478\n\nroom:\n  auto_create: true\n  max_participants: 100\n  empty_timeout: 300\n\nwebhook:\n  api_key: '<LIVEKIT_API_KEY>'   # CRITICAL - see Gotcha #6\n  urls:\n    - \"http://fluxer-server:8080/api/webhooks/livekit\"\n```\n\n## Step 4: Fix the Dockerfile\n\nThe upstream `fluxer_server/Dockerfile` requires several modifications to build successfully. Apply these changes in the source repo before building:\n\n### 4a. Update package list in Dockerfile\n\nThe `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.\n\nCompare `ls packages/` with the COPY lines and update accordingly. At time of writing, there are ~47 packages.\n\n### 4b. Add Rust/WASM toolchain to app-build stage\n\nThe `fluxer_app` frontend requires Rust + wasm-pack for WebAssembly compilation. Add to the `app-build` stage:\n\n```dockerfile\nFROM deps AS app-build\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    ca-certificates \\\n    pkg-config \\\n    libssl-dev \\\n    && rm -rf /var/lib/apt/lists/*\n\nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.93.0 --target wasm32-unknown-unknown\nENV PATH=\"/root/.cargo/bin:${PATH}\"\nRUN cargo install wasm-pack\n```\n\n> **Gotcha #1: Missing `ca-certificates`.** The slim Debian base image doesn't include CA certificates. Without it, `curl` to download rustup fails with SSL errors.\n\n### 4c. Fix `.dockerignore` exclusions\n\nThe `.dockerignore` excludes files needed for the build:\n\n```dockerignore\n# Comment out or remove these lines:\n# /fluxer_app/src/data/emojis.json\n# /fluxer_app/src/locales/*/messages.js\n\n# Add exceptions for build scripts (the **/build pattern catches them):\n!fluxer_app/scripts/build\n!fluxer_app/scripts/build/**\n```\n\n> **Gotcha #2: `.dockerignore` `**/build` pattern.** This glob catches `fluxer_app/scripts/build/rspack/lingui.mjs` which is needed at build time. The exclusion of locale files and emojis.json also breaks TypeScript compilation.\n\n### 4d. Add build config, domain, and Lingui compilation\n\nThe 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:\n\n```dockerfile\nCOPY config/config.production.template.json /tmp/fluxer-build-config.json\n\nARG BASE_DOMAIN=\"chat.example.com\"\nRUN sed -i \"s/chat\\.example\\.com/${BASE_DOMAIN}/g\" /tmp/fluxer-build-config.json\n\nARG FLUXER_CDN_ENDPOINT=\"\"\nENV FLUXER_CONFIG=/tmp/fluxer-build-config.json\nENV FLUXER_CDN_ENDPOINT=${FLUXER_CDN_ENDPOINT}\nRUN cd fluxer_app && pnpm lingui:compile && pnpm build\n```\n\n> **Gotcha #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.\n\n### 4e. Fix CDN endpoint for self-hosting\n\nIn `fluxer_app/rspack.config.mjs`, the CDN endpoint defaults to `https://fluxerstatic.com`:\n\n```javascript\n// BEFORE (broken for self-hosting):\nconst CDN_ENDPOINT = process.env.FLUXER_CDN_ENDPOINT || 'https://fluxerstatic.com';\n\n// AFTER (respects empty string):\nconst CDN_ENDPOINT = 'FLUXER_CDN_ENDPOINT' in process.env ? process.env.FLUXER_CDN_ENDPOINT : 'https://fluxerstatic.com';\n```\n\n> **Gotcha #4: JavaScript `||` treats empty string as falsy.** Setting `FLUXER_CDN_ENDPOINT=\"\"` still falls through to the CDN URL. Must use `in` operator or `??` with explicit `undefined` check. Without this fix, all JS/CSS bundles point to `fluxerstatic.com` instead of being served locally.\n\n### 4f. Fix ENTRYPOINT\n\n```dockerfile\n# BEFORE (broken - root workspace has no start script):\nENTRYPOINT [\"pnpm\", \"start\"]\n\n# AFTER:\nENTRYPOINT [\"pnpm\", \"--filter\", \"fluxer_server\", \"start\"]\n```\n\n### 4g. Fix CSP for external font/icon CDN\n\nThe 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.\n\nIn `fluxer_server/src/ServiceInitializer.tsx`, find the `cspDirectives` object in `createAppServerInitializer` and add `https://fluxerstatic.com` to `styleSrc`, `imgSrc`, and `fontSrc`:\n\n```typescript\ncspDirectives: {\n    // ...\n    styleSrc: [\"'self'\", \"'unsafe-inline'\", 'https://fluxerstatic.com'],\n    imgSrc: [\"'self'\", 'data:', 'blob:', publicUrlHost, mediaUrlHost, 'https://fluxerstatic.com'],\n    fontSrc: [\"'self'\", 'https://fluxerstatic.com'],\n    // ...\n},\n```\n\n> **Gotcha #11: Monolith CSP blocks `fluxerstatic.com`.** The HTML template loads IBM Plex fonts and favicon from the public `fluxerstatic.com` CDN, 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.\n\n## Step 5: Build the Image\n\n```bash\ncd /srv/fluxer/fluxer\ndocker build \\\n  -t fluxer-server:local \\\n  --build-arg BASE_DOMAIN=\"fluxer.yourdomain.com\" \\\n  --build-arg FLUXER_CDN_ENDPOINT=\"\" \\\n  --build-arg INCLUDE_NSFW_ML=true \\\n  -f fluxer_server/Dockerfile .\n```\n\nBuild takes ~20-30 minutes on first run (downloads Rust toolchain, compiles WASM, builds Erlang gateway). Subsequent builds with cached layers are much faster.\n\n- `BASE_DOMAIN` — your Fluxer domain. Baked into the frontend JS for API endpoint discovery. **Must match `domain.base_domain` in config.json.**\n- `FLUXER_CDN_ENDPOINT=\"\"` — empty string for self-hosting (assets served from same origin). Without this, all JS/CSS loads from `fluxerstatic.com`.\n- `INCLUDE_NSFW_ML=true` — copies the ONNX model for NSFW image detection into the image.\n\n## Step 6: Create docker-compose.yml\n\nKey points for the compose file:\n\n- `fluxer-server` needs `depends_on` with health checks for `fluxer-valkey` and `fluxer-meilisearch`\n- Config mounted read-only: `./config:/usr/src/app/config:ro`\n- Data volume for SQLite + file storage: `fluxer-data:/usr/src/app/data`\n- LiveKit needs host ports: `7881:7881/tcp`, `3478:3478/udp`, `50000-50100:50000-50100/udp`\n- Both `fluxer-server` and `fluxer-livekit` need Traefik labels on the `proxy` network\n- All internal services on a private `fluxer-internal` network\n\n> **Gotcha #5: `static_dir` must be in config.json, not just an env var.** The server reads `Config.services.server.static_dir` from the JSON config file, not from `FLUXER_SERVER_STATIC_DIR` env var. Without it, the health check shows `app: disabled` and the SPA doesn't serve.\n\n> **Gotcha #6: LiveKit webhook requires `api_key` field.** The `webhook` section in `livekit.yaml` must include `api_key` alongside `urls`. Without it, LiveKit enters a restart loop with `api_key is required to use webhooks`.\n\n> **Gotcha #7: NATS is required, not optional.** The server uses NATS JetStream for its job queue (cron tasks, background processing). Without both `fluxer-nats-core` and `fluxer-nats-jetstream` containers, the server fatally crashes with `CONNECTION_REFUSED` on startup. Deploy both as simple `nats:2-alpine` containers — core on port 4222, JetStream on port 4223 with `--jetstream --store_dir /data`.\n\n## Step 7: Open Firewall Ports\n\n```bash\nsudo ufw allow 7881/tcp comment 'Fluxer LiveKit ICE TCP'\nsudo ufw allow 3478/udp comment 'Fluxer LiveKit TURN/STUN'\nsudo ufw allow 50000:50100/udp comment 'Fluxer LiveKit RTP media'\n```\n\nNote: Docker-published ports bypass UFW, but documenting them keeps `ufw status` accurate.\n\n## Step 8: Create DNS Records\n\nCreate two Cloudflare A records pointing to your server IP:\n\n| Name | Type | Value | Proxy |\n|------|------|-------|-------|\n| `fluxer` | A | `<server-ip>` | Proxied (orange cloud) |\n| `lk` | A | `<server-ip>` | Proxied (orange cloud) |\n\n**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.\n\nLiveKit 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.\n\n## Step 9: Deploy\n\n```bash\ncd /srv/fluxer\ndocker compose up -d\n```\n\n## Verification\n\n```bash\n# All containers running\ndocker ps --filter \"name=fluxer\"\n\n# Health check (all services should be \"healthy\")\ncurl -s https://fluxer.yourdomain.com/_health | jq\n\n# Web app loads\ncurl -sI https://fluxer.yourdomain.com/\n\n# LiveKit signaling responds\ncurl -sI https://lk.yourdomain.com/\n```\n\nExpected health response:\n```json\n{\n  \"status\": \"healthy\",\n  \"services\": {\n    \"kv\": { \"status\": \"healthy\" },\n    \"s3\": { \"status\": \"healthy\" },\n    \"jetstream\": { \"status\": \"healthy\" },\n    \"mediaProxy\": { \"status\": \"healthy\" },\n    \"admin\": { \"status\": \"healthy\" },\n    \"api\": { \"status\": \"healthy\" },\n    \"app\": { \"status\": \"healthy\" }\n  }\n}\n```\n\nThen open `https://fluxer.yourdomain.com` in a browser and register the first user account.\n\n## Summary of Gotchas\n\n| # | Issue | Symptom | Fix |\n|---|---|---|---|\n| 1 | Missing `ca-certificates` in build | SSL errors downloading rustup | Add `ca-certificates` to `apt-get install` in app-build stage |\n| 2 | `.dockerignore` too aggressive | TypeScript errors (missing locales, emojis, build scripts) | Comment out exclusions, add `!fluxer_app/scripts/build` exception |\n| 3 | `FLUXER_CONFIG` not set during build | `FLUXER_CONFIG must be set` crash | Copy production template and set env var in Dockerfile |\n| 4 | `\\|\\|` treats `\"\"` as falsy in JS | All assets point to `fluxerstatic.com` CDN | Use `in` operator check in rspack.config.mjs |\n| 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 |\n| 6 | LiveKit webhook needs `api_key` | LiveKit restart loop | Add `api_key` field to webhook section in livekit.yaml |\n| 7 | NATS is a hard dependency | Fatal `CONNECTION_REFUSED` on startup | Deploy `fluxer-nats-core` and `fluxer-nats-jetstream` containers |\n| 8 | Dockerfile package list outdated | Build fails on missing `package.json` files | Update COPY list to match actual packages in monorepo |\n| 9 | No `pnpm start` in root workspace | `Missing script: start` | Change ENTRYPOINT to `pnpm --filter fluxer_server start` |\n| 10 | GHCR image is private | `pull access denied` | Build from source using the `refactor` branch |\n| 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 |\n| 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 |\n| 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 |\n| 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/`) |\n| 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 |\n| 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 |\n| 17 | `URLSearchParams` body becomes `\"{}\"` | OIDC token exchange fails with \"grant_type missing\" 400 | Add `.toString()` to the `URLSearchParams` in `SsoService.exchangeCode()` |\n| 18 | SSO client secret not loaded | Token exchange sends no secret, \"empty client secret\" 400 | Pass `{includeSecret: true}` to `getSsoConfig()` in `SsoService.getResolvedConfig()` |\n| 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 |\n\n## SSO (OIDC) Integration\n\nFluxer 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`.\n\nThree source code bugs must be fixed before SSO will work:\n\n### SSO Fix 1: Callback route not in standalone route list (Gotcha #16)\n\nThe `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.\n\nIn `fluxer_app/src/router/components/RootComponent.tsx`, add `/auth/sso/` to the `isStandaloneRoute` check:\n\n```typescript\npathname.startsWith(Routes.CONNECTION_CALLBACK) ||\npathname.startsWith('/auth/sso/') ||       // ADD THIS LINE\npathname === '/__notfound' ||\n```\n\n### SSO Fix 2: Token exchange sends empty body (Gotcha #17)\n\nThe `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\".\n\nIn `packages/api/src/auth/services/SsoService.tsx`, add `.toString()` to convert the `URLSearchParams` to a proper URL-encoded string:\n\n```typescript\nconst body = new URLSearchParams({\n    grant_type: 'authorization_code',\n    code,\n    redirect_uri: config.redirectUri,\n    client_id: config.clientId ?? '',\n    code_verifier: codeVerifier,\n}).toString();  // ADD .toString() — without it, JSON.stringify produces \"{}\"\n```\n\n### SSO Fix 3: Client secret not loaded for token exchange (Gotcha #18)\n\nThe `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\".\n\nIn `packages/api/src/auth/services/SsoService.tsx`, in `getResolvedConfig()`:\n\n```typescript\n// BEFORE:\nconst stored = await this.instanceConfigRepository.getSsoConfig();\n\n// AFTER:\nconst stored = await this.instanceConfigRepository.getSsoConfig({includeSecret: true});\n```\n\n### SSO Fix 4: Timeout masks actual error message (Gotcha #19)\n\nThe `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.\"\n\nIn `fluxer_app/src/components/pages/SsoCallbackPage.tsx`, add `clearTimeout(timeoutId)` in all early-return, success, and error paths within the async IIFE.\n\n### SSO Fix 5: SSO users treated as \"unclaimed\" — all restrictions apply (Gotcha #20)\n\nFluxer 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`.\n\n**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.\n\n**Fix**: In `packages/api/src/models/User.tsx`, update `isUnclaimedAccount()` to exclude SSO users (who get `sso` and `sso:{providerId}` traits at provisioning time):\n\n```typescript\nisUnclaimedAccount(): boolean {\n    return this.passwordHash === null && !this.isBot && !this._traits.has('sso');\n}\n```\n\nThis single change fixes all unclaimed restrictions across the entire codebase at once, since every check site calls `isUnclaimedAccount()`.\n\n## Admin Panel\n\n### First user gets admin automatically\n\nThe 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.\n\n### Granting admin to additional users\n\nIf you need to grant admin access to other users after the initial setup, you can do so via SQLite:\n\n```bash\n# Find the user's ID\ndocker exec fluxer-server sqlite3 /usr/src/app/data/fluxer.db \\\n  \"SELECT id, username FROM users WHERE username = 'targetuser';\"\n\n# Grant wildcard admin ACL\ndocker exec fluxer-server sqlite3 /usr/src/app/data/fluxer.db \\\n  \"INSERT INTO admin_acls (user_id, permission) VALUES ('<user-id>', '*');\"\n```\n\nThe admin panel is accessible at `https://fluxer.yourdomain.com/admin`.\n\n## Resource Usage\n\n| Container | RAM | Notes |\n|---|---|---|\n| fluxer-server | ~300-500MB | Includes Node.js + embedded Erlang gateway |\n| fluxer-valkey | ~50-100MB | Grows with cached data |\n| fluxer-meilisearch | ~100-200MB | Scales with indexed messages |\n| fluxer-livekit | ~50-100MB idle | Scales with active voice sessions |\n| fluxer-nats-core | ~20-30MB | Lightweight pub/sub |\n| fluxer-nats-jetstream | ~30-50MB | Grows with queue data |\n| **Total** | **~550-1000MB** | |\n", "url": "https://wpnews.pro/news/fluxer-self-hosted-deployment-guide-refactor-branch-20-gotchas-documented", "canonical_source": "https://gist.github.com/PaulMColeman/e7ef82e05035b24300d2ea1954527f10", "published_at": "2026-02-26 05:39:54+00:00", "updated_at": "2026-05-22 12:15:11.523116+00:00", "lang": "en", "topics": ["open-source", "developer-tools", "cloud-computing", "cybersecurity", "products"], "entities": ["Fluxer", "Docker", "Traefik", "Cloudflare", "Node.js", "TypeScript", "Erlang", "React"], "alternates": {"html": "https://wpnews.pro/news/fluxer-self-hosted-deployment-guide-refactor-branch-20-gotchas-documented", "markdown": "https://wpnews.pro/news/fluxer-self-hosted-deployment-guide-refactor-branch-20-gotchas-documented.md", "text": "https://wpnews.pro/news/fluxer-self-hosted-deployment-guide-refactor-branch-20-gotchas-documented.txt", "jsonld": "https://wpnews.pro/news/fluxer-self-hosted-deployment-guide-refactor-branch-20-gotchas-documented.jsonld"}}