Skip to content

Get Started

Run your own Fluxer instance with Docker Compose. This guide takes you from a fresh server to a working self-hosted instance with the web app, API, gateway, admin dashboard, media uploads, search, storage, and voice signaling behind one public hostname.

What you'll run

The self-hosted stack is one Docker Compose project:

  • Caddy terminates public HTTP(S) or receives traffic from a Cloudflare Tunnel.
  • App proxy serves the Fluxer web client and injects instance bootstrap data.
  • API handles accounts, auth, communities, messages, uploads, admin APIs, and instance discovery.
  • Admin dashboard is required and is served at /admin.
  • Gateway handles WebSocket sessions, presence, dispatch, push fanout, and realtime events.
  • Messages service builds message responses and serves message history.
  • Media proxy handles attachment upload relay, media metadata, thumbnails, and object reads.
  • Static proxy serves Fluxer fonts, icons, emoji, badges, default avatars, and voice client assets from the same hostname.
  • LiveKit handles voice and video signaling and WebRTC media.
  • Postgres, Valkey, NATS, Meilisearch, and SeaweedFS provide data, cache, events, search, and S3-compatible object storage.

The app bundle is served by the self-host app-proxy image; shared static assets are served by the standalone static-proxy container. The stack does not depend on Fluxer's public static asset host.

Requirements

  • A Linux server or VM that can run Docker Engine.
  • Docker Engine plus the Docker Compose plugin.
  • A hostname for the instance, for example chat.example.com.
  • Either public inbound 80/tcp and 443/tcp, or a Cloudflare Tunnel that routes the hostname to the Caddy container.
  • For production voice and video media, a public path to 7881/tcp and 7882/udp.
  • At least 2 vCPU, 4 GB RAM, and 20 GB disk. Use 4 vCPU and 8 GB RAM or more for a small active community.

The stack idles around a few GB of memory, and startup is the heaviest point because all service images initialize at once.

Step 1: Install Docker

Install Docker Engine from Docker's official instructions for your distribution:

Confirm the versions:

docker --version
docker compose version

Use Docker Engine 24 or newer and the Compose v2 plugin.

Step 2: Download the stack

Create a working directory and download the stack files:

mkdir fluxer
cd fluxer

base=https://raw.githubusercontent.com/fluxerapp/fluxer/main/deploy/self-hosting
curl -fsSLO "$base/docker-compose.yml"
curl -fsSLO "$base/Caddyfile"
curl -fsSLO "$base/livekit.yaml"
curl -fsSL "$base/.env.example" -o .env

You should now have:

Caddyfile
docker-compose.yml
livekit.yaml
.env

Step 3: Configure .env

Set the public hostname at the top of .env.

For a normal public server where Caddy obtains certificates directly:

FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
FLUXER_VAPID_EMAIL=admin@example.com

For a Cloudflare Tunnel where Cloudflare terminates HTTPS and forwards HTTP to Caddy:

FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
FLUXER_CADDY_SITE_ADDRESS=:80
FLUXER_VAPID_EMAIL=admin@example.com

FLUXER_PUBLIC_SCHEME and FLUXER_PUBLIC_PORT describe what users see in their browser. FLUXER_CADDY_SITE_ADDRESS describes what Caddy listens on inside the stack.

Generate the required secrets:

for key in POSTGRES_PASSWORD MEILI_MASTER_KEY FLUXER_S3_SECRET_KEY \
  FLUXER_SUDO_MODE_SECRET FLUXER_CONNECTION_INITIATION_SECRET \
  FLUXER_GATEWAY_RPC_AUTH_TOKEN FLUXER_MEDIA_PROXY_SECRET_KEY \
  FLUXER_ADMIN_SECRET_KEY_BASE FLUXER_ADMIN_OAUTH_CLIENT_SECRET \
  LIVEKIT_API_SECRET; do
  sed -i "s|^$key=.*|$key=$(openssl rand -hex 32)|" .env
done

sed -i "s|^FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=.*|FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=$(openssl rand -base64 32)|" .env

VAPID=$(docker run --rm node:24-alpine npx --yes web-push generate-vapid-keys --json)
pub=$(printf '%s' "$VAPID" | grep -o '"publicKey":"[^"]*"' | cut -d'"' -f4)
priv=$(printf '%s' "$VAPID" | grep -o '"privateKey":"[^"]*"' | cut -d'"' -f4)
sed -i "s|^FLUXER_VAPID_PUBLIC_KEY=.*|FLUXER_VAPID_PUBLIC_KEY=$pub|" .env
sed -i "s|^FLUXER_VAPID_PRIVATE_KEY=.*|FLUXER_VAPID_PRIVATE_KEY=$priv|" .env

Keep these defaults unless you know you need to change them:

  • LIVEKIT_API_KEY=fluxer; the secret is LIVEKIT_API_SECRET.
  • FLUXER_S3_ACCESS_KEY=fluxer; the secret is FLUXER_S3_SECRET_KEY.
  • Email starts disabled. Enable SMTP later from .env and the admin dashboard.

Keep .env private

.env contains every secret for the instance. Do not commit it, paste it into support tickets, or put it in screenshots.

Step 4: Publish the hostname

Create DNS records for the hostname:

  • A record from chat.example.com to the server IPv4 address.
  • Optional AAAA record from chat.example.com to the server IPv6 address.

Leave FLUXER_CADDY_SITE_ADDRESS=chat.example.com. Caddy will request and renew certificates automatically when 80/tcp and 443/tcp can reach the server.

Use this when the server should not expose public web ports.

  1. Set FLUXER_CADDY_SITE_ADDRESS=:80.
  2. In Cloudflare, create a Tunnel public hostname for your Fluxer domain.
  3. If cloudflared runs inside the Compose project, point the public hostname service to http://caddy:80.
  4. If cloudflared runs directly on the host, point the public hostname service to http://127.0.0.1:80.

A temporary Compose override keeps the tunnel next to Caddy without saving the token in your main stack:

cat > cloudflared.compose.yml <<'YAML'
services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel run --token ${CLOUDFLARED_TOKEN:?set CLOUDFLARED_TOKEN}
    depends_on:
      - caddy
YAML

export CLOUDFLARED_TOKEN='paste-your-tunnel-token-here'
docker compose -f docker-compose.yml -f cloudflared.compose.yml up -d cloudflared

Voice media is not carried by a normal public hostname tunnel

The web app, API, admin dashboard, gateway WebSocket, media proxy HTTP routes, and LiveKit signaling can work through the tunnel. LiveKit WebRTC media still needs reachable 7881/tcp and 7882/udp, or a TURN deployment.

Step 5: Open the firewall

If you are using a direct public server, allow inbound:

  • 22/tcp or your SSH port.
  • 80/tcp and 443/tcp for Caddy.
  • 7881/tcp and 7882/udp for LiveKit media.

If you are using a Cloudflare Tunnel for web traffic, you can block inbound 80/tcp and 443/tcp at the provider firewall. Keep LiveKit media closed too unless you are intentionally exposing voice/video media or using a TURN server.

Provider firewall first

Docker-published ports can bypass host firewalls such as UFW because Docker installs its own packet-filtering rules. Prefer your cloud provider's firewall or security group for internet-facing policy.

Step 6: Start the stack

Start Fluxer:

docker compose up -d

If you are using the Cloudflare override from above, start both files together:

docker compose -f docker-compose.yml -f cloudflared.compose.yml up -d

Watch the startup:

docker compose ps
docker compose logs -f api

The first start can take several minutes while images download and services initialize. seaweedfs-init exits after creating object-storage buckets; that is expected.

Step 7: Verify the instance

Set your domain in the shell:

export FLUXER_DOMAIN=chat.example.com

Check every public HTTP entry point:

for path in /_health /api/_health /gateway/_health /media/_health /admin/_health; do
  curl -fsS -o /tmp/fluxer-check -w "$path %{http_code}\n" "https://$FLUXER_DOMAIN$path"
done

Expected result:

/_health 200
/api/_health 200
/gateway/_health 200
/media/_health 200
/admin/_health 200

Check instance discovery:

curl -fsS "https://$FLUXER_DOMAIN/api/.well-known/fluxer" | jq '.features.self_hosted, .endpoints.admin, .endpoints.gateway, .endpoints.media, .endpoints.static_cdn'

You should see true, an admin URL ending in /admin, a gateway URL ending in /gateway, a media URL ending in /media, and a static asset URL equal to the instance origin.

Check the web app, admin login page, app bundle, and static asset container:

curl -fsSI "https://$FLUXER_DOMAIN" | sed -n '1,8p'
curl -fsSI "https://$FLUXER_DOMAIN/admin/login" | sed -n '1,8p'

asset=$(curl -fsS "https://$FLUXER_DOMAIN" | grep -o 'src="[^"]*/assets/[^"]*"' | head -n1 | cut -d'"' -f2)
case "$asset" in
  http*) curl -fsSI "$asset" | sed -n '1,8p' ;;
  /*) curl -fsSI "https://$FLUXER_DOMAIN$asset" | sed -n '1,8p' ;;
esac

curl -fsSI "https://$FLUXER_DOMAIN/fonts/ibm-plex.css?v=3" | sed -n '1,8p'
curl -fsSI "https://$FLUXER_DOMAIN/web/favicon-32x32.png" | sed -n '1,8p'

If you are using Cloudflare Tunnel and see HTTP 530, the tunnel connector is not currently connected or the public hostname route points at the wrong service.

Step 8: Create the owner account

Open the web app:

https://chat.example.com

Register the first account. On a self-hosted instance, the first accepted registration receives wildcard admin access. Use that account for the initial admin login:

https://chat.example.com/admin

Complete the initial setup from the admin dashboard. At minimum, review:

  • Branding and instance name.
  • Registration mode: open, approval, or closed.
  • Email delivery.
  • Captcha policy if you open public registration.
  • Single-community mode if you want one default community instead of many user-created communities.
  • Voice regions and LiveKit reachability if you are enabling voice.

Email

Email is disabled by default. To enable SMTP, set these in .env and restart api, worker, and admin:

FLUXER_EMAIL_ENABLED=true
FLUXER_EMAIL_PROVIDER=smtp
FLUXER_EMAIL_FROM_EMAIL=noreply@example.com
FLUXER_EMAIL_FROM_NAME=Fluxer
FLUXER_EMAIL_SMTP_HOST=smtp.example.com
FLUXER_EMAIL_SMTP_PORT=587
FLUXER_EMAIL_SMTP_USERNAME=example
FLUXER_EMAIL_SMTP_PASSWORD=example-secret
FLUXER_EMAIL_SMTP_SECURE=true

Then test the SMTP configuration from /admin/instance-config.

Voice and video

Fluxer uses LiveKit for voice and video. Caddy routes /livekit to LiveKit's HTTP/WebSocket signaling port, but browser media flows over WebRTC:

  • 7882/udp is the normal media path.
  • 7881/tcp is the TCP fallback path.
  • 7880/tcp stays private behind Caddy for signaling.

On a VPS with 7881/tcp and 7882/udp open, LiveKit can usually auto-detect the public IP. Behind NAT, Cloudflare Tunnel, or restrictive networks, add a TURN server and configure LiveKit for it.

Backups

Back up these items before upgrades and on a regular schedule:

  • .env
  • postgres-data
  • seaweedfs-data

For a cold backup:

docker compose stop api worker gateway admin app-proxy media-proxy static-proxy livekit
docker run --rm -v fluxer_postgres-data:/data -v "$PWD/backups:/backup" alpine tar czf /backup/postgres-data.tgz -C /data .
docker run --rm -v fluxer_seaweedfs-data:/data -v "$PWD/backups:/backup" alpine tar czf /backup/seaweedfs-data.tgz -C /data .
docker compose up -d

For production, prefer a Postgres-native dump plus object-storage backup so you do not need to stop the instance.

Upgrading

The default image tag is v1, which tracks the latest compatible release:

docker compose pull
docker compose up -d

The fluxer-static image is part of the default stack, so static asset updates are picked up by the same pull-and-restart flow.

To pin a specific release, set FLUXER_IMAGE_TAG in .env to the release tag you want, then pull and restart.

Getting help

  • File issues and follow development on GitHub.
  • For direct access to the team, see Operator Pass.