What you will set up
Fluxer is a chat app in the style of Discord: people join communities, which have text channels, voice channels and roles, and they can also send each other direct messages. Voice channels carry calls with sound, video and screen sharing. It runs in the browser, and there are apps for Windows, macOS, Linux, Android and iOS, which can connect to your own server.
Fluxer is made by Fluxer Platform AB, a self-funded company in Sweden started by the developer Hampus Kraft. It is open source under the GNU AGPL v3, and the company says self-hosting stays free. Fluxer publishes its own container images and documents running your own instance; this guide follows that setup, written as Podman Quadlet files instead of Docker Compose.
A Fluxer instance is many small programs working together, 25 containers in all, under a user of its own called fluxer, behind Caddy from the Podman guide:
- the Fluxer services themselves: the API, a background worker, the gateway that keeps every app connected, a media proxy for uploads and images, the admin dashboard, the web app, and five small internal services, each with a router and a shard;
- PostgreSQL for the data, Valkey as a cache, NATS for messages between the services and Meilisearch for search;
- Garage for uploaded files, the S3 store from the Garage guide, here inside the Fluxer setup instead of Fluxer's default, SeaweedFS;
- LiveKit, the media server for voice and video;
- a small Caddy, called the edge, that sends each request to the right service.
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- The site was online before the admin account existed, and a sign-up from outside was refused with "This instance is not set up yet". The admin account was created with the setup token from Fluxer's log.
- With registration closed, the sign-up page said "Registration is currently closed". A second person signed up with a one-time registration link from the admin dashboard, and joined the community through an invite link.
- A community was created with a second text channel and a Moderator role, which was given to the second person. Messages went both ways, a direct message arrived, search found a word, an image showed with a preview, and files of 3 MB and 150 MB downloaded with the same checksum.
- Two browsers held a video call in a voice channel, over UDP and, with UDP blocked, over TCP, with sound and video both ways.
- The admin dashboard showed each person's real IPv4 address.
- The app was shown in Swedish, both by choice and in a browser set to Swedish.
- Through a test mail server on port 587, a password reset email arrived, in Swedish, and its link set a new password.
- A backup was restored into a new instance with every account, message and file, Fluxer was updated with
podman auto-update, and everything came back by itself after a reboot.
Fluxer used about 850 MB of memory when idle, and about 1 GB with two people in a video call. The images take about 4 GB of disk. Fluxer's own documentation asks for 8 GB of memory, the Alloy plan, and 16 GB for a small, busy community.
Before you start
You need:
- a server set up as in the Podman guide, with Caddy running, and with ufw as in the security guide;
- an A record and an AAAA record for
chat.example.compointing at your server; - for email, an account with your email provider that can send through SMTP (step 12).
The examples use chat.example.com for Fluxer and 203.0.113.10 for your server's IPv4 address, which ip -brief address show eth0 shows. Replace them throughout.
1. Open the call ports and create the user
As root, raise the limit on how much incoming UDP Linux holds for a program. LiveKit receives calls over UDP, and warns at start when the limit is lower:
echo "net.core.rmem_max = 5000000" > /etc/sysctl.d/60-livekit.conf
sysctl --system
Open the two ports LiveKit uses for calls:
ufw allow 7891/tcp
ufw allow 7892/udp
Sound and video travel over UDP port 7892. TCP port 7891 is the fallback for networks that block UDP. These are the only ports Fluxer needs besides HTTPS. Fluxer's own setup uses 7881 and 7882, the same as Matrix calls, so this guide moves them up by ten to let both run on one server.
Then create the user:
useradd -m -s /bin/bash fluxer
loginctl enable-linger fluxer
machinectl shell fluxer@
Everything up to step 6 runs as fluxer.
2. Create the secrets
Fluxer needs a long list of keys and passwords, which the services use to trust each other and to sign logins. Create them all as random values, stored by Podman and not in the files below:
for s in db-password meili-key garage-rpc sudo-mode-secret connection-secret pseudonym-secret gateway-rpc-token erlang-cookie media-proxy-key admin-key-base admin-oauth-secret; do
openssl rand -hex 32 | tr -d '\n' | podman secret create $s -
done
openssl rand -base64 32 | tr -d '\n' | podman secret create upload-relay-secret -
secret=$(openssl rand -hex 32)
printf 'fluxer: %s' "$secret" | podman secret create livekit-keys -
printf %s "$secret" | podman secret create livekit-secret -
unset secret
LiveKit and Fluxer share the last one: livekit-keys is the key LiveKit checks, named fluxer, and livekit-secret the same secret for Fluxer.
Browser notifications need a key pair of their own, which Fluxer requires even if nobody turns notifications on:
openssl ecparam -name prime256v1 -genkey -noout -out vapid.pem
openssl ec -in vapid.pem -outform DER 2>/dev/null | tail -c +8 | head -c 32 | openssl base64 -A | tr '+/' '-_' | tr -d '=' | podman secret create vapid-private-key -
openssl ec -in vapid.pem -pubout -outform DER 2>/dev/null | tail -c 65 | openssl base64 -A | tr '+/' '-_' | tr -d '=' | podman secret create vapid-public-key -
rm vapid.pem
The public key is not secret, but keeping it next to the private key means the two always match, also in a backup.
3. Write the configuration
Create ~/fluxer.env, the settings every Fluxer service shares:
FLUXER_ENV=production
FLUXER_SELF_HOSTED=true
FLUXER_BASE_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
FLUXER_TRUST_CLIENT_IP_HEADER=true
FLUXER_APP_ENDPOINT=https://chat.example.com
FLUXER_MARKETING_ENDPOINT=https://chat.example.com
FLUXER_ADMIN_ENDPOINT=https://chat.example.com/admin
FLUXER_MEDIA_ENDPOINT=https://chat.example.com/media
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=https://chat.example.com/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=https://chat.example.com/media
FLUXER_LIVEKIT_URL=wss://chat.example.com/livekit
FLUXER_PASSKEY_RP_ID=chat.example.com
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
FLUXER_VAPID_EMAIL=admin@chat.example.com
FLUXER_INTERNAL_API_ENDPOINT=http://api:8080
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT=http://media-proxy:8080
FLUXER_MEDIA_PROXY_ENDPOINT=http://media-proxy:8080
FLUXER_DATABASE_BACKEND=postgres
FLUXER_POSTGRES_HOST=postgres
FLUXER_KV_URL=redis://valkey:6379/0
FLUXER_NATS_URL=nats://nats:4222
FLUXER_NATS_JETSTREAM_URL=nats://nats:4222
FLUXER_SVC_NATS_URL=nats://nats:4222
FLUXER_SVC_SHARD_COUNT=1
FLUXER_SEARCH_ENGINE=meilisearch
FLUXER_SEARCH_URL=http://meilisearch:7700
FLUXER_S3_ENDPOINT=http://garage:3900
FLUXER_S3_PUBLIC_ENDPOINT=http://garage:3900
FLUXER_S3_REGION=garage
FLUXER_S3_FORCE_PATH_STYLE=true
FLUXER_S3_BUCKET_CDN=fluxer
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED=false
FLUXER_LIVEKIT_ENABLED=true
FLUXER_LIVEKIT_API_KEY=fluxer
FLUXER_LIVEKIT_INTERNAL_URL=http://livekit:7880
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}
FLUXER_ACCOUNT_IDENTITY=email
FLUXER_GIFS_SHARD_CACHE_MAX_BYTES=134217728
Create ~/garage.toml, the configuration of the file store:
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
metadata_fsync = true
replication_factor = 1
rpc_bind_addr = "127.0.0.1:3901"
rpc_public_addr = "127.0.0.1:3901"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
Create ~/livekit.yaml, with your server's IPv4 address:
port: 7880
rtc:
tcp_port: 7891
udp_port: 7892
use_external_ip: false
node_ip: 203.0.113.10
webhook:
api_key: fluxer
urls:
- http://api:8080/webhooks/livekit
And ~/Caddyfile.edge, the routes of the edge, the same as in Fluxer's own setup:
{
admin off
servers {
trusted_proxies static private_ranges
trusted_proxies_strict
}
}
:8080 {
encode zstd gzip
handle /_health {
respond "OK" 200
}
handle_path /api/* {
reverse_proxy api:8080
}
handle /gateway {
rewrite * /
reverse_proxy gateway:8080
}
handle_path /gateway/* {
reverse_proxy gateway:8080
}
handle_path /media/* {
reverse_proxy media-proxy:8080
}
handle_path /livekit/* {
reverse_proxy livekit:7880
}
handle /admin {
rewrite * /
reverse_proxy admin:8080
}
handle_path /admin/* {
reverse_proxy admin:8080
}
@static path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/*
handle @static {
reverse_proxy static-proxy:8080
}
handle /.well-known/fluxer {
reverse_proxy api:8080
}
handle {
reverse_proxy app-proxy:8080
}
}
:8088 {
handle /.well-known/fluxer {
reverse_proxy api:8080
}
}
What the settings do:
- Fluxer builds every address it gives the apps from
FLUXER_BASE_DOMAIN, the scheme and the port, and the lines withhttps://chat.example.comspell them out. They must be the publichttps://address, even though Caddy talks to Fluxer over plain HTTP. FLUXER_TRUST_CLIENT_IP_HEADER=truelets Fluxer read each visitor's address from the header the edge sets. The edge trusts that header only from private addresses, such as Caddy's connection through Podman.- The names such as
postgres,garageandapiare the containers of the next step, which reach each other by name on a network of their own. FLUXER_S3_REGIONmust matchs3_regioningarage.toml, or Garage refuses the requests. Uploads go through Fluxer's media proxy, so browsers never talk to Garage.FLUXER_ACCOUNT_IDENTITY=emailmakes people sign in with their email address, which lets Fluxer send password resets once email works (step 12). The alternative,username, needs no email at all and gives each person a recovery key instead. The choice is fixed when the first account is created.- In
livekit.yaml,node_ipis the address LiveKit gives the apps for calls. Fluxer's own setup asks a Google STUN server for it instead; naming it here saves that lookup. FLUXER_GIFS_SHARD_CACHE_MAX_BYTESis from Fluxer's settings for an 8 GB server.
4. Define the containers
Podman starts each container as a service from a Quadlet file, as in the Podman guide. Create the directories:
mkdir -p ~/.config/systemd/user ~/.config/containers/systemd/container.d ~/.config/containers/systemd/fluxer-.container.d
cd ~/.config/containers/systemd
A target groups the containers, so that one command starts or stops all of Fluxer. Create ~/.config/systemd/user/fluxer.target:
[Unit]
Description=Fluxer
[Install]
WantedBy=default.target
Then the network the containers share. Create fluxer.network:
[Network]
NetworkName=fluxer
Settings in a .d directory are added to several files at once. Those in container.d apply to every container of this user, which only runs Fluxer. Create container.d/10-all.conf:
[Unit]
PartOf=fluxer.target
[Container]
Network=fluxer.network
AutoUpdate=registry
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=fluxer.target
Those in fluxer-.container.d apply to every file whose name starts with fluxer-, which are Fluxer's own services. They all get the shared settings and the secrets. Create fluxer-.container.d/20-fluxer.conf:
[Unit]
Wants=postgres.service valkey.service nats.service meilisearch.service garage.service livekit.service
After=postgres.service valkey.service nats.service meilisearch.service garage.service livekit.service
[Container]
EnvironmentFile=%h/fluxer.env
Secret=db-password,type=env,target=FLUXER_POSTGRES_PASSWORD
Secret=meili-key,type=env,target=FLUXER_SEARCH_API_KEY
Secret=s3-key-id,type=env,target=FLUXER_S3_ACCESS_KEY_ID
Secret=s3-secret-key,type=env,target=FLUXER_S3_SECRET_ACCESS_KEY
Secret=livekit-secret,type=env,target=FLUXER_LIVEKIT_API_SECRET
Secret=sudo-mode-secret,type=env,target=FLUXER_SUDO_MODE_SECRET
Secret=connection-secret,type=env,target=FLUXER_CONNECTION_INITIATION_SECRET
Secret=pseudonym-secret,type=env,target=FLUXER_PROFILE_PSEUDONYM_SECRET
Secret=gateway-rpc-token,type=env,target=FLUXER_GATEWAY_RPC_AUTH_TOKEN
Secret=erlang-cookie,type=env,target=FLUXER_ERLANG_COOKIE
Secret=media-proxy-key,type=env,target=FLUXER_MEDIA_PROXY_SECRET_KEY
Secret=upload-relay-secret,type=env,target=FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64
Secret=admin-key-base,type=env,target=FLUXER_ADMIN_SECRET_KEY_BASE
Secret=admin-oauth-secret,type=env,target=FLUXER_ADMIN_OAUTH_CLIENT_SECRET
Secret=vapid-private-key,type=env,target=FLUXER_VAPID_PRIVATE_KEY
Secret=vapid-public-key,type=env,target=FLUXER_VAPID_PUBLIC_KEY
Now the services Fluxer keeps its data in. Create postgres.container:
[Container]
ContainerName=postgres
Image=docker.io/library/postgres:16-alpine
Exec=postgres -c max_connections=150 -c shared_buffers=384MB -c effective_cache_size=1536MB -c jit=off -c shared_preload_libraries=pg_stat_statements
ShmSize=1g
PodmanArgs=--memory=2560m
Volume=fluxer-postgres:/var/lib/postgresql/data
Environment=POSTGRES_DB=fluxer POSTGRES_USER=fluxer
Secret=db-password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U fluxer -d fluxer
HealthInterval=10s
Notify=healthy
valkey.container:
[Container]
ContainerName=valkey
Image=docker.io/valkey/valkey:9.1-alpine
Exec=valkey-server --appendonly yes --appendfsync everysec --dir /data --maxmemory 192mb --maxmemory-policy noeviction
Volume=fluxer-valkey:/data
HealthCmd=valkey-cli ping
HealthInterval=10s
Notify=healthy
nats.container:
[Container]
ContainerName=nats
Image=docker.io/library/nats:2.14-alpine
Exec=-js -sd /data -m 8222
Volume=fluxer-nats:/data
HealthCmd=wget -q -O /dev/null http://127.0.0.1:8222/healthz
HealthInterval=10s
Notify=healthy
meilisearch.container:
[Container]
ContainerName=meilisearch
Image=docker.io/getmeili/meilisearch:v1.53
PodmanArgs=--memory=1g
Volume=fluxer-meilisearch:/meili_data
Environment=MEILI_ENV=production MEILI_NO_ANALYTICS=true MEILI_MAX_INDEXING_MEMORY=192mb MEILI_MAX_INDEXING_THREADS=2
Secret=meili-key,type=env,target=MEILI_MASTER_KEY
HealthCmd=wget -q -O /dev/null http://127.0.0.1:7700/health
HealthInterval=10s
Notify=healthy
garage.container:
[Container]
ContainerName=garage
Image=docker.io/dxflrs/garage:v2.4.1
Exec=/garage server --single-node
Volume=%h/garage.toml:/etc/garage.toml:ro
Volume=fluxer-s3-meta:/var/lib/garage/meta
Volume=fluxer-s3-data:/var/lib/garage/data
Secret=garage-rpc,type=env,target=GARAGE_RPC_SECRET
StopTimeout=30
And livekit.container, the only one besides the edge with ports on the server:
[Container]
ContainerName=livekit
Image=docker.io/livekit/livekit-server:v1.12.0
PublishPort=7891:7891/tcp
PublishPort=7892:7892/udp
PodmanArgs=--memory=512m
Volume=%h/livekit.yaml:/etc/livekit.yaml:ro
Exec=--config /etc/livekit.yaml
Secret=livekit-keys,type=env,target=LIVEKIT_KEYS
These are the versions Fluxer's own setup uses. Podman updates PostgreSQL, Valkey, NATS and Meilisearch within those versions (step 15). Garage and LiveKit have their version written out.
Next, Fluxer's five internal services. Each runs twice, as a router and a shard, from the same image, so two template files cover all ten. The %i in them is replaced by the service's name. Create fluxer-svc@.container:
[Unit]
Description=Fluxer %i service, router
[Container]
ContainerName=%i
Image=ghcr.io/fluxerapp/fluxer-%i:v1
Environment=FLUXER_SVC_NAME=%i FLUXER_SVC_MODE=router
And fluxer-shard@.container:
[Unit]
Description=Fluxer %i service, shard
[Container]
ContainerName=%i-shard
Image=ghcr.io/fluxerapp/fluxer-%i:v1
Environment=FLUXER_SVC_NAME=%i FLUXER_SVC_MODE=shard FLUXER_SVC_SHARD_ID=0
A link with the service's name in it turns a template into a service:
for s in snowflakes users messages gifs unfurl; do
ln -s fluxer-svc@.container fluxer-svc@$s.container
ln -s fluxer-shard@.container fluxer-shard@$s.container
done
Now the main services. Create fluxer-api.container:
[Unit]
Description=Fluxer API
Wants=fluxer-svc@snowflakes.service fluxer-shard@snowflakes.service fluxer-svc@users.service fluxer-shard@users.service fluxer-svc@messages.service fluxer-shard@messages.service fluxer-svc@gifs.service fluxer-shard@gifs.service
After=fluxer-svc@snowflakes.service fluxer-shard@snowflakes.service fluxer-svc@users.service fluxer-shard@users.service fluxer-svc@messages.service fluxer-shard@messages.service fluxer-svc@gifs.service fluxer-shard@gifs.service
[Container]
ContainerName=api
Image=ghcr.io/fluxerapp/fluxer-api:v1
PodmanArgs=--memory=1280m
Environment=FLUXER_API_PORT=8080 FLUXER_POSTGRES_MAX_CONNECTIONS=25
HealthCmd=["node", "-e", "fetch('http://127.0.0.1:8080/_health').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
HealthInterval=10s
HealthStartPeriod=90s
Notify=healthy
fluxer-worker.container, the background jobs:
[Unit]
Description=Fluxer background worker
Wants=fluxer-svc@snowflakes.service fluxer-shard@snowflakes.service fluxer-svc@users.service fluxer-shard@users.service fluxer-svc@messages.service fluxer-shard@messages.service
After=fluxer-svc@snowflakes.service fluxer-shard@snowflakes.service fluxer-svc@users.service fluxer-shard@users.service fluxer-svc@messages.service fluxer-shard@messages.service
[Container]
ContainerName=worker
Image=ghcr.io/fluxerapp/fluxer-api:v1
Exec=node dist/WorkerEntrypoint.js
PodmanArgs=--memory=1280m
Environment=FLUXER_API_WORKER_MODE=all_lanes FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER=true FLUXER_POSTGRES_MAX_CONNECTIONS=25
fluxer-gateway.container, which every app keeps a connection to:
[Container]
ContainerName=gateway
Image=ghcr.io/fluxerapp/fluxer-gateway:v1
PodmanArgs=--memory=512m
Environment=FLUXER_GATEWAY_PORT=8080
fluxer-media-proxy.container, for uploads, images and files:
[Container]
ContainerName=media-proxy
Image=ghcr.io/fluxerapp/fluxer-media-proxy:v1
PodmanArgs=--memory=512m
Environment=FLUXER_MEDIA_PROXY_PORT=8080 FLUXER_MEDIA_PROXY_MODE=upload FLUXER_MEDIA_PROXY_STORAGE_BACKEND=s3
Environment=FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com
fluxer-push.container, for notifications:
[Unit]
Wants=fluxer-api.service
After=fluxer-api.service
[Container]
ContainerName=push
Image=ghcr.io/fluxerapp/fluxer-push:v1
fluxer-admin.container, the admin dashboard:
[Unit]
Wants=fluxer-api.service
After=fluxer-api.service
[Container]
ContainerName=admin
Image=ghcr.io/fluxerapp/fluxer-admin:v1
Environment=FLUXER_ADMIN_PORT=8080 FLUXER_ADMIN_BASE_PATH=/admin FLUXER_API_ENDPOINT=http://api:8080
Environment=FLUXER_STATIC_CDN_ENDPOINT=https://chat.example.com
Last, the three that serve the web app and route the requests. Their names do not start with fluxer-, as they need none of the shared settings. Create app-proxy.container:
[Unit]
Wants=fluxer-api.service
After=fluxer-api.service
[Container]
ContainerName=app-proxy
Image=ghcr.io/fluxerapp/fluxer-app-proxy-self-hosted:v1
Environment=FLUXER_APP_PROXY_PORT=8080 DISCOVERY_UPSTREAM_URL=http://edge:8088/.well-known/fluxer
static-proxy.container:
[Container]
ContainerName=static-proxy
Image=ghcr.io/fluxerapp/fluxer-static:v1
And edge.container:
[Container]
ContainerName=edge
Image=docker.io/library/caddy:2.11-alpine
# Only Caddy, on this server, can reach Fluxer: the port is not open to the internet.
PublishPort=127.0.0.1:8125:8080
Volume=%h/Caddyfile.edge:/etc/caddy/Caddyfile:ro
The memory limits are the ones Fluxer recommends for a server with 8 GB. They are ceilings, not reservations, and keep one busy service from taking the memory of the others. v1 is the tag that follows every compatible release of Fluxer.
5. Create the storage and start Fluxer
Start Garage on its own, and give it a short name for its command:
systemctl --user daemon-reload
systemctl --user start garage
echo "alias garage='podman exec -e RUST_LOG=warn garage /garage'" >> ~/.bashrc
source ~/.bashrc
garage status
garage status lists one healthy node. If it says "Connection refused", Garage is still starting: wait a few seconds and run it again. Create the four buckets Fluxer uses, a key for Fluxer with access to them, and store the key as two more secrets:
garage key create fluxer
for b in fluxer fluxer-uploads fluxer-reports fluxer-harvests; do
garage bucket create $b
garage bucket allow --read --write --owner $b --key fluxer
done
garage key info fluxer --show-secret | awk '/^Key ID/ {printf "%s", $3}' | podman secret create s3-key-id -
garage key info fluxer --show-secret | awk '/^Secret key/ {printf "%s", $3}' | podman secret create s3-secret-key -
Start everything:
systemctl --user enable --now fluxer.target
systemctl --user enable --now podman-auto-update.timer
The first start downloads about 4 GB of images and takes a few minutes. enable starts Fluxer at every boot. Fluxer is ready when this prints 200 for each address:
for p in /_health /api/_health /gateway/_health /media/_health /.well-known/fluxer /; do
printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8125$p)" "$p"
done
podman ps lists 25 containers. Then find the setup token, which you need in step 7:
journalctl --user -u fluxer-api | grep -o '"setup_token":"[^"]*"' | tail -1
6. Put Caddy in front
Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:
chat.example.com {
reverse_proxy 127.0.0.1:8125
}
Restart Caddy with systemctl --user restart caddy.
7. Create the admin account
Open https://chat.example.com. A new instance shows its setup, and nobody can create an account without the setup token from step 5: in our test, a sign-up from outside was refused with "This instance is not set up yet. Finish setup in a web browser with the setup token before creating accounts." That is why the site can go online before the admin account exists. An SSH tunnel to the server is no use here, because the web app always talks to https://chat.example.com.
Go through the setup:
- Pick the language and choose Next, then paste the setup token.
- Choose a theme. Under Choose how people sign in, Email and No tags are selected, from
fluxer.env. - Create the administrator account with your email address, a username and a password. The first account becomes the administrator.
- Under Make the instance yours, give the instance a Product name, which the app and emails show instead of Fluxer.
- Under Who can join, choose Closed. Step 8 lets people in by invitation.
- Leave Single community mode and Disable direct messages and friend requests off, unless you want one community only, which can only be chosen now.
- Leave Enable attachment expiration off to keep files until someone deletes them. The steps for GIF search, YouTube, email, Bluesky and mobile push can wait. Leave the push relay notice unaccepted for now; step 13 explains it.
- Under Choose the premium model, Everyone is premium gives everyone the highest limits, such as files of up to 500 MB, by Fluxer's documentation; we uploaded 150 MB. Fluxer sells no premium on your instance unless you set up Stripe.
- Check the summary, and choose Finish setup.
The admin dashboard is at https://chat.example.com/admin. It logs in with your Fluxer account, and has users, communities, reports, bans and every instance setting.
8. Invite people
With registration closed, https://chat.example.com/register says "Registration is currently closed. Use a registration link from an admin to create an account."
To let someone in, open the admin dashboard, Instance Config, and under Create Admin-Issued Registration URL give the link a Label, such as the person's name, Expires in days and Max uses, such as 7 and 1, and choose Create Registration URL. The link, https://chat.example.com/register?registration_url=..., appears under Existing Registration URLs. Send it to the person; it lets them create one account, and Revoke stops it. Tick Approval required to approve each account before it can be used.
To open registration instead, change Registration Policy on the same page to Open, or to Approval required, where every sign-up waits under Pending Registrations.
9. Communities, channels and roles
A community is what Discord calls a server: a place with its own channels, members and roles.
- Choose + (Create or join a community) on the left, Create community, give it a name, and Create community. It starts with a text channel,
#general, and a voice channel, General. - Open the menu under the community's name. Create channel adds a text, voice, announcement, forum or media channel, and Private channel limits it to the members and roles you pick.
- Invite members in the same menu gives an invite link, valid for seven days, for people who already have an account.
- Community settings > Roles > Create role adds a role, such as
Moderator, with its permissions. Save changes stores it. To give someone the role, select them in the member list, and choose Add role.
Messages arrive at once for everyone in the channel. The + next to the message box has Upload file, and images show with a preview. To send a direct message, select a person and choose Message. The search box at the top searches the messages of the community.
Uploaded files are reachable by anyone who has the file's exact address, without logging in, much as on Discord. Keep that in mind before you share passwords or customer data as files.
10. Voice and video
Select a voice channel to join it. The bar at the bottom has the microphone, Turn on camera and Share your screen, and Disconnect leaves the channel. Everyone in the channel hears and sees each other, through LiveKit on your server.
In our test, two browsers called each other with sound and video over UDP port 7892, and over TCP port 7891 when UDP was blocked, with no lost packets. If a call does not connect, see Troubleshooting.
The desktop and mobile apps work too: when they ask for an instance, enter chat.example.com. We only tested the web app.
11. Use Fluxer in Swedish
Fluxer is translated into Swedish, and shows Swedish to anyone whose browser is set to it. To choose it by hand, open the settings with the gear icon next to your name, then Language > Interface language, and pick Svenska. Emails from Fluxer, such as password resets, follow each person's language.
12. Email
Fluxer sends email to confirm new addresses and to reset passwords. Without email set up:
- the sign-in page has no Forgot your password? link, and Send Password Reset for a user in the admin dashboard also sends an email, so nobody can reset a forgotten password;
- addresses are accepted without confirmation.
If you will not set up email, choose FLUXER_ACCOUNT_IDENTITY=username in step 3 before the first account exists: people then sign in with a username and get a recovery key for a forgotten password, and an administrator can create a reset link for them. We did not test that.
Store the SMTP password of your email account as a secret, as fluxer:
printf %s 'the-smtp-password' | podman secret create smtp-password -
Add these lines to ~/fluxer.env, with your own server, account and sender address:
FLUXER_EMAIL_ENABLED=true
FLUXER_EMAIL_PROVIDER=smtp
FLUXER_EMAIL_FROM_EMAIL=chat@example.com
FLUXER_EMAIL_SMTP_HOST=mail.example.com
FLUXER_EMAIL_SMTP_PORT=587
FLUXER_EMAIL_SMTP_USERNAME=chat@example.com
FLUXER_EMAIL_SMTP_TLS_MODE=starttls
And this line under [Container] in both fluxer-api.container and fluxer-worker.container, the two services that send email:
Secret=smtp-password,type=env,target=FLUXER_EMAIL_SMTP_PASSWORD
Restart them:
systemctl --user daemon-reload
systemctl --user restart fluxer-api fluxer-worker
Port 587 with starttls encrypts the connection before the login; for port 465, use implicit. Once email is on, Forgot your password? appears when signing in. In our test, the reset email arrived in the person's language, and its link set a new password and signed out their other sessions. We sent it to a test mail server on the server itself, without encryption, so the provider settings above were not tested.
13. Outside services
We captured the server's DNS lookups and outgoing connections while Fluxer started, while two people wrote, uploaded files and held a video call, and while it sat idle for 25 minutes. Fluxer sent no usage data and made no update checks. What we saw:
- When someone posts a link, the server fetches the page to show a preview, so the linked site sees your server's address. In our capture, a link to
www.debian.orgcaused a lookup and a connection to it. - While Fluxer restarted, the names of its own containers, such as
nats, were looked up at the server's DNS resolver for a few seconds, until the containers were back.
Other services are off until you turn them on:
- Mobile push: the official Android and iOS apps get notifications only through Fluxer's push relay, run by Fluxer, which hands them to Apple and Google. It is used only after you accept its privacy notice, in the setup or in the admin dashboard. Without it, the mobile apps get no notifications when closed. Browsers use their own push service, such as Google's for Chrome, once a person turns notifications on.
- GIF search needs a key from Klipy, and YouTube previews a YouTube Data API key, both in the admin dashboard.
- Fluxer can check passwords against the Have I Been Pwned list and block links and files from public blocklists. Both are off on a self-hosted instance.
Fluxer's own setup has LiveKit ask Google's STUN servers for the server's address when it starts; node_ip in livekit.yaml replaces that. The images come from GitHub's registry, ghcr.io, and Docker Hub, which Podman contacts to update them.
14. Back up
As fluxer:
umask 077
mkdir -p ~/backup/secrets
podman exec postgres pg_dump -U fluxer -d fluxer --format=custom > ~/backup/fluxer.dump
for s in $(podman secret ls --format '{{.Name}}'); do
printf %s "$(podman secret inspect --showsecret --format '{{.SecretData}}' $s)" > ~/backup/secrets/$s
done
systemctl --user stop fluxer.target
until [ -z "$(podman ps -aq)" ]; do sleep 1; done
for v in fluxer-s3-meta fluxer-s3-data fluxer-valkey fluxer-nats fluxer-meilisearch; do
podman volume export $v > ~/backup/$v.tar
done
systemctl --user start fluxer.target
tar -cf ~/backup/config.tar fluxer.env garage.toml livekit.yaml Caddyfile.edge .config/containers/systemd .config/systemd/user/fluxer.target
That saves what Fluxer's own backup advice asks for, and a little more:
fluxer.dumphas every account, community, message and setting. The dump runs while Fluxer keeps working.systemctl --user stop fluxer.targetreturns before every container has stopped, so theuntilline waits for them.- The Garage volumes have the uploaded files, avatars and images. Valkey and NATS hold queued work, such as files waiting to be deleted, and Meilisearch the search index, which is slow to rebuild. These are copied with Fluxer stopped, for about a minute.
secretshas every key from step 2, in plain text: without them, Fluxer cannot use the restored data.config.tarhas the files from steps 3 and 4.
Copy ~/backup to another machine, for example with restic, and keep it private.
To restore, as fluxer, with the backup in ~/backup. On a new server, first do step 1, then tar -xf ~/backup/config.tar -C ~ instead of steps 2 to 4, and change node_ip in livekit.yaml if the server's address is new. Then:
systemctl --user stop fluxer.target
until [ -z "$(podman ps -aq)" ]; do sleep 1; done
podman volume rm fluxer-postgres fluxer-s3-meta fluxer-s3-data fluxer-valkey fluxer-nats fluxer-meilisearch
for f in ~/backup/secrets/*; do podman secret rm $(basename $f) 2>/dev/null; podman secret create $(basename $f) $f; done
for v in fluxer-s3-meta fluxer-s3-data fluxer-valkey fluxer-nats fluxer-meilisearch; do
podman volume create $v
podman volume import $v ~/backup/$v.tar
done
systemctl --user daemon-reload
systemctl --user start postgres
podman exec -i postgres pg_restore -U fluxer -d fluxer --no-owner < ~/backup/fluxer.dump
systemctl --user enable --now fluxer.target
On an existing server, podman volume rm deletes the instance's current data, so run the backup first. On a new server, the first lines complain about volumes and a target that do not exist yet, which is fine. Starting PostgreSQL on its own gives it an empty database, which the dump fills before Fluxer starts. On a new server, finish with step 6, systemctl --user enable --now podman-auto-update.timer and the alias line from step 5.
In our test, the backup of one instance was restored into a newly installed one, and every account, community, role, message, direct message, file and search result came back. A second backup was restored the new-server way, under a newly created fluxer user, and people could log in and read and download everything again.
15. Update
podman auto-update checks once a day for new images of every container, and restarts those that changed. Fluxer publishes new images several times a day under v1, and updates its database by itself when it starts. To update now:
podman auto-update
Before larger updates, read the release notes on Fluxer's GitHub page and take a backup. To hold a version, replace v1 with a release tag, such as 2026.1014.3120 for the API, and change it when you want to update; every Fluxer image has its own release numbers. Fluxer's setup moves PostgreSQL to a new major version only by hand, through a dump and restore, which its documentation describes.
Troubleshooting
A service does not start. systemctl --user status fluxer-api and journalctl --user -u fluxer-api show why, and the same for every other container. podman ps -a lists them all.
The setup asks for a token you no longer have. Look it up again with the journalctl line from step 5. Fluxer logs it once, on the first start.
A call connects, but nobody hears or sees anyone. Ports 7891 and 7892 cannot be reached. Check ufw status for the rules from step 1, and that node_ip in livekit.yaml is your server's IPv4 address. podman logs livekit shows the calls.
Uploads fail. podman logs media-proxy shows the error. If Garage refuses the key, compare garage key info fluxer with podman secret inspect --showsecret s3-key-id. FLUXER_S3_REGION must be garage.