Self-Hosting My Way Out of Discord: Introducing Fluxer v2

Self-Hosting My Way Out of Discord: Introducing Fluxer v2
Fluxer's logo

Discord announced a bunch of things in February that made me want my own door key. I've been looking for the exit for a while for a number of reasons, and Fluxer shipping a self-hostable v2 was the nudge. So I stood one up.

This is the long version of what it took, written down mostly so the next person who tries this on a box that already runs other things doesn't spend an afternoon staring at a 403.

The short version: it works. Voice, video, screen share, the whole thing. But the path there ran through a broken setup wizard, two services fighting over the same ports, a reverse proxy that already belonged to something else, Cloudflare doing exactly what you'd expect and nothing more, and a firewall rule I typed wrong with my own hands. Let's go.

The setup, and why it was the hard mode

The instance is Fluxer v2, the multi-container split layout from the self-hosting code that landed publicly on June 15. It's running on a VPS that was already busy. That box runs a Matrix instance via matrix-docker-ansible-deploy, including Element Call, which means it already owned a Traefik, already owned ports 80 and 443, and already ran a LiveKit for Matrix's voice.

So almost none of my pain was Fluxer's fault, specifically. It was the pain of being the second guest at a party where the first guest already put their coat on every chair. If you're deploying onto an empty box, you can skip about half of this. If you're not, welcome, pull up a chair, assuming you can find one.

It's fronted by Cloudflare on fluxer.example.com, proxied, orange cloud on.

The wizard doesn't finish

First thing you hit is the setup wizard. Pick "Single Community," fill it out, try to finish, and it just doesn't. The request to complete setup comes back 400 from POST /admin/instance-config/update and the wizard sits there.

That's a known bug, #1007, open as of this writing with no fix yet. I didn't figure out the root cause, just that the wizard's Single Community path is broken right now.

So everything I describe below is the manual way in. If your wizard works by the time you read this, lucky you, skip ahead. If it 400s, that's not you, that's the bug, and you configure the instance by hand instead.

Sharing a Traefik you didn't bring

Fluxer ships its own Caddy that wants to terminate TLS and own 80 and 443. My box already has Traefik doing that for Matrix. Two things can't bind the same port, so Caddy had to step back and sit behind the existing Traefik.

The trick is Caddy is doing more than TLS. It's the internal router for /api, /gateway, /media, /admin, the static assets, and the app-proxy discovery endpoint. You don't want to bypass it. You want it to stop owning the host ports and serve plain HTTP internally while Traefik handles the cert.

In .env:

FLUXER_CADDY_SITE_ADDRESS=:8080

That one line flips Caddy's main site from "be a public HTTPS server doing its own ACME" to "serve HTTP on 8080 and let someone else worry about TLS." The bundled Caddyfile reads that variable for its site address, so no Caddyfile editing needed. The separate hardcoded :8088 block that feeds the app-proxy discovery is untouched and keeps working.

Then the Caddy service in compose loses its ports: block entirely, joins Traefik's network, and picks up labels:

  caddy:
    image: caddy:2.10-alpine
    restart: unless-stopped
    networks:
      - fluxer
      - traefik
    environment:
      FLUXER_CADDY_SITE_ADDRESS: ${FLUXER_CADDY_SITE_ADDRESS:-:8080}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=traefik"
      - "traefik.http.routers.fluxer.rule=Host(`fluxer.example.com`)"
      - "traefik.http.routers.fluxer.entrypoints=web-secure"
      - "traefik.http.routers.fluxer.tls.certResolver=default"
      - "traefik.http.services.fluxer.loadbalancer.server.port=8080"

And declare Traefik's network as external at the bottom:

networks:
  fluxer:
    driver: bridge
  traefik:
    external: true

Here's the part that'll waste your time if you assume: matrix-docker-ansible-deploy does not name its Traefik bits the way the docs examples do. The HTTPS entrypoint is named web-secure, but its actual address internally is :8443, which Traefik then publishes to host 443. The name and the address are different things, and you want the name in your label. The cert resolver was default. Don't guess either one, read them out of the running config:

docker network ls | grep traefik
grep -B1 'address: :8443' /matrix/traefik/config/traefik.yml
grep -A2 certificatesResolvers /matrix/traefik/config/traefik.yml

Confirm the network name, the entrypoint name on the :443-equivalent address, and the resolver name. Plug those exact strings into the labels. If voice config is the part that 403s later, wrong labels here are the part that 404s now.

A second LiveKit walks into the bar

The box already runs a LiveKit for Element Call, sitting on the default ports. Fluxer wants a LiveKit too. They cannot both have 7881 and 7882.

So Fluxer's gets remapped. In .env:

FLUXER_LIVEKIT_TCP_PORT=17881
FLUXER_LIVEKIT_UDP_PORT=17882

The non-obvious rule, and the one I want to save you from: the port LiveKit advertises to clients is the port it binds inside the container, because use_external_ip: true makes it hand out its own address. So a clever 17882:7882 host-to-container remap will quietly break media, because clients get told to dial 7882 while the host only listens on 17882.

The container port, the livekit.yaml port, and the advertised port all have to be the same number. Edit the shipped livekit.yaml to match:

rtc:
  tcp_port: 17881
  udp_port: 17882
  use_external_ip: true

And publish them straight through, no number-changing:

    ports:
      - "17881:17881"
      - "17882:17882/udp"

This LiveKit runs single-port UDP mux, one udp_port, no port range. That's fine. It means you don't need to open a 50000-60000 block, just the two ports. Hold that thought, the firewall comes back to bite later.

Cloudflare, doing exactly what it says

TLS through an orange-clouded Cloudflare has one gotcha and one trap.

The gotcha: Traefik issues the origin cert via an HTTP-01 challenge, and that works fine through Cloudflare's proxy, because Cloudflare passes /.well-known/acme-challenge/ through to origin as long as your SSL mode is Full or Full (Strict). Not Flexible. Flexible loops. The cert issued without me doing anything special.

The trap: when you check the cert from your laptop with openssl s_client, you see Cloudflare's edge cert, issued by Google Trust Services, not your origin cert. That's correct and expected, the orange cloud means you terminate at Cloudflare. To see what Traefik actually issued, check from the box itself against loopback:

echo | openssl s_client -connect 127.0.0.1:443 -servername fluxer.example.com 2>/dev/null | openssl x509 -noout -issuer -dates

If that shows Let's Encrypt, your origin cert is real. Once it's confirmed real and trusted, you can move Cloudflare from Full to Full (Strict), because now strict validation of the origin actually has something valid to validate. I'd been on plain Full and there was no longer any reason to be.

One thing to file away for August: Traefik auto-renews this over HTTP-01, and the only thing that breaks that is a Cloudflare rule that starts intercepting /.well-known/*. Don't add one.

Voice: the 403 that was actually an empty list

This is where I lost the most time, so here's the whole shape of it.

Click to join a voice channel. The client spins, then "Voice server timeout," then disconnects. Server side, the only smoke is a single POST /internal/rpc returning 403. Nothing in the browser network tab.

The instinct is "secret mismatch," and I chased that for a while. The gateway RPC auth token matched across api and gateway. The worker was healthy. The LiveKit key map was correct, key named fluxer, same secret on both sides. All fine. All red herrings.

The actual tell was in the worker logs, on every reconciliation sweep:

"liveKitServersSearched":0

Zero. Not "can't reach a LiveKit," but "there are no LiveKit servers registered to even try." The instance had no voice server in its pool. So when you join, the API tries to allocate a room, finds no server to allocate on, and the internal RPC that's supposed to hand back a voice server assignment 403s because there's nothing valid to hand back. The 403 was the symptom. The empty list was the disease.

LiveKit being enabled and credentialed in .env is not enough. You have to register a voice region and server in the admin panel. Under instance settings there's a voice section, and it was empty. I added a region and a server, and the sweeps immediately started showing liveKitServersSearched:1, roomCount:1, totalConsistent:1, and the RPC went green.

Two details when you register that server, both of which I got wrong first:

The client-facing URL must be the public ingress path, not the internal Docker hostname. My first attempt used http://livekit:7880, which your browser cannot resolve and Cloudflare's CSP blocks anyway. The Caddyfile already routes /livekit/* to the LiveKit signaling port, so the browser-facing URL is https://fluxer.example.com/livekit. The API talking to LiveKit container-to-container can use the internal address, but the value handed to the client has to be the public one.

And then the Content-Security-Policy. After fixing the URL, the WebSocket still got blocked, because the generated connect-src shipped with the hosted Fluxer's own origins and didn't include wss://fluxer.example.com. The signaling connection is wss://, and if your domain isn't in connect-src for WebSocket, the browser kills it before it opens. Anyone self-hosting on a custom domain hits this. Get your own wss:// origin into the instance's CSP.

The firewall foot-gun

With all of that fixed, voice and video worked. Screen share timed out on the first try, then worked on the second. Negotiation timeout, "failed to set recv parameters," then fine on retry. That specific "stalls then succeeds" pattern is almost always ICE and UDP reachability rather than anything in the app.

It was me. I'd opened 17881 for both TCP and UDP in the VPS firewall. But the media split is 17881/tcp and 17882/udp. So my actual UDP media port, 17882, was never allowed inbound, and I had a useless UDP rule sitting on 17881 where nothing listens.

The reason it looked like it worked at all is that media was quietly falling back to TCP on 17881, which I had opened. TCP fallback carries audio and video well enough that you don't notice. But it's slower to negotiate, and screen share's mid-session renegotiation is the most timing-sensitive thing in the stack, so it was the one operation that kept timing out before the fallback caught it.

The correct inbound rules:

  • 17881/tcp, the fallback, keep it
  • 17882/udp, the actual primary media port, open it
  • 17881/udp, drop it, nothing listens there

Opened 17882/udp, dropped the stray rule, and screen share started working on the first try. Voice quality got better too, since the whole thing stopped running over the TCP fallback and started preferring UDP like it's supposed to.

Confirm your listeners and that your published ports actually agree with what's open:

ss -ulnp | grep -E '17882'

First-run admin things worth knowing

A couple of operational notes for a fresh instance, especially with the wizard bug forcing you to do this by hand.

The first account to register gets wildcard admin and access to /admin. Register yourself first, before you open the doors to anyone else.

With open registration disabled, you manage access through admin-generated invites, which live in the admin instance config and get approved there. So "closed signup plus invites" is the workable model, you're just driving it from the admin panel rather than a public registration form.

The part that just worked

After all of that, here's a thing that was pleasantly turnkey: Discord template import. I pulled a server template off an existing Discord server and imported it into my self-hosted instance, and the channel and category structure and roles came across cleanly. No fight.

That matters more than it sounds, because the whole reason I'm doing this is to get out of the Discord ecosystem, and the single biggest friction in leaving a place is recreating everything you built there by hand. Being able to lift the structure of a server over in one move turns "I'll migrate someday" into "I'll migrate this weekend." It lowers the activation energy on the exit, which is exactly what I needed it to do.

The clients can't follow you yet

Here's the asterisk on the whole thing, and it's a real one.

Right now the only client that can talk to a self-hosted instance is the web app. The Windows desktop app and the Android beta don't expose any way to log into a custom instance. No server field, no obvious command-line argument to point them at your own backend. I opened a discussion asking about it and haven't gotten an answer yet.

The roadmap and the dev blog say the desktop client is supposed to support custom backends through in-app account switching, as the launch self-hosting story. But what's actually shipped in the apps today doesn't do it. That's a gap between the plan and the binary, and it's worth knowing before you talk anyone into following you off Discord: today, your people connect through a browser. Desktop and mobile aren't there yet.

For me that's a "watch the discussion and wait" situation, not a dealbreaker. The web client does everything. But if your pitch to a community is "come to my server, install the app," that pitch doesn't work this week.

Gotchas, condensed

For the skimmers, the things that actually cost time:

  • The Single Community setup wizard 400s (#1007). Configure by hand.
  • matrix-docker-ansible-deploy's Traefik entrypoint is named web-secure even though its address is :8443. Read the real names out of the config, don't guess.
  • LiveKit advertises the port it binds inside the container. Keep container, yaml, and published port the same number.
  • HTTP-01 works through Cloudflare's orange cloud on Full or Full (Strict), not Flexible. And openssl from your laptop shows the edge cert, not your origin cert. Check origin from loopback.
  • Voice does nothing until you register a region and server in /admin. liveKitServersSearched:0 is the tell.
  • The voice server's client URL is the public /livekit path, not the internal Docker hostname.
  • Your domain's wss:// origin has to be in the instance CSP connect-src.
  • Media ports are tcp on one number and udp on the next. Open the right one for the right protocol.
  • The LiveKit webhook api_key in livekit.yaml is a key name that has to exist in the keys map. If you generate a random API key, fix the webhook name to match.
  • If real-time hangs while page load works, check the /gateway WebSocket upgrade through Cloudflare. It proxies WebSockets fine by default, but it's the first place to look.

Was it worth it

Yeah. A few hours of fighting ports and proxies to own the thing my friends talk in, instead of being beholden to an app that epitomizes the word enshittification from a company that keeps changing the terms. The voice is clear, the structure imported in one move, and the only thing standing between me and telling everyone to come over is a native client that the roadmap says is coming.

We'll still be kicking the tires awhile before fully migrating away from Discord, but I have my own door now. I just can't hand out app-shaped keys yet.