docs: move the reference site to astro starlight (#2527)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:40:22 +02:00
1 parent 7f8f09ee51
commit 5367c0ab42
198 files changed
+59927 -1216

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,281 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Get started
description: Bring up a Fluxer instance with Docker Compose, from a fresh host to first login.
---
import InstallerChecksum from '@/components/InstallerChecksum.astro';
:::tip[Plutonium and donations fund Fluxer]
We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
:::
By the end you have a Fluxer instance on a hostname you own, with the web app, HTTP API, Gateway, admin dashboard, uploads, search and voice signalling behind it. All of it runs in Docker containers on one machine. Allow about 30 minutes, most of it spent downloading images.
## What you need
| Requirement | Value |
| --- | --- |
| Host | Any machine that runs Docker with Linux containers. A Linux server, or a Mac or Windows PC with Docker Desktop |
| Runtime | Docker Engine 24 or newer with the Compose plugin v2.20.2 or newer. Docker Desktop ships both |
| Processor | Intel, AMD and Apple Silicon all work. Every image ships for `amd64` and `arm64`, and Docker pulls the matching one |
| CPU | 2 vCPU minimum, 4 vCPU for a small active community. No service sets a CPU limit, so every container sees all of them |
| RAM | 8 GB minimum with the shipped defaults, 16 GB for a small active community. 4 GB needs the [low-memory overrides](/operator/configuration/#resources) |
| Disk | 20 GB minimum, more for uploads |
| Inbound ports | 80/tcp, 443/tcp and 443/udp for the web tier, plus 7881/tcp and 7882/udp for voice media |
| DNS | An `A` record for the hostname pointing at this machine, optionally an `AAAA` record |
| Outbound | Working DNS resolution from inside the containers |
| Host tools | `curl` and `openssl` on Linux. macOS ships both. The Windows installer needs neither |
The per-container memory limits are ceilings and reserve nothing, so the host does not need their total free. On a 4 GB host, lower the `api` and `worker` limits first, because those two size their JavaScript heap from the limit they are given.
Docker Desktop runs the containers inside a Linux virtual machine, so the RAM figure above is the RAM its settings give that machine, which starts well below what the hardware has.
The stack is one Compose project. The edge and LiveKit are the only services that publish ports.
The steps below use the bundled Caddy, which obtains and renews a TLS certificate for the hostname and needs no configuration.
To put your own proxy in front, add the `docker-compose.proxy.yml` overlay. The stack then leaves 80 and 443 alone and publishes one plain HTTP port, and your proxy forwards everything to that port. [Behind your own reverse proxy](/operator/reverse-proxy/) has the overlay, the requirements, and worked configuration for nginx, Caddy, Traefik, HAProxy, Apache, a Cloudflare Tunnel and Nginx Proxy Manager.
## Step 1: Install Docker
Check what you have:
```bash
docker --version
docker compose version
```
On Debian and Ubuntu, use Docker's apt repository from [Install Docker Engine](https://docs.docker.com/engine/install/). The distribution's `docker.io` package ships no Compose plugin. Add your user to the `docker` group with the [post-installation steps](https://docs.docker.com/engine/install/linux-postinstall/), then log out and back in so the commands below run without `sudo`.
On a Mac or a Windows PC, install [Docker Desktop](https://docs.docker.com/desktop/) and leave it on Linux containers. It has both commands above.
On Linux, install the two host tools the steps below use. On Debian and Ubuntu:
```bash
sudo apt-get install -y curl openssl
```
Other distributions use `dnf`, `zypper`, `pacman` or `apk` with the same two package names. `curl --version` and `openssl version` both answer once the install finishes.
## Step 2: Point DNS at the host
Choose the hostname the instance answers on. Step 4 writes it into `.env` as `FLUXER_DOMAIN`, and every example below uses `chat.example.com` in its place.
Create an `A` record for that hostname pointing at the machine's public address, and an `AAAA` record when the machine has a public IPv6 address. A hosting provider shows that address in its console. On a home connection the address belongs to the router, and the router's status page shows it.
:::caution[Finish setup before anyone else reaches the hostname]
An unconfigured instance takes registrations from anyone, and lets any account that registers read and change the instance configuration. Registration defaults to `open`. The window opens the moment DNS and the ports are live, so finish the wizard in [Step 6](#first-run) in the same sitting that brings the stack up.
:::
## Step 3: Open the ports
Open six ports on the host:
| Port | Protocol | Used by |
| --- | --- | --- |
| 22 | tcp | SSH, on a host you reach remotely |
| 80 | tcp | ACME HTTP validation and the redirect to HTTPS |
| 443 | tcp | The web app, HTTP API, Gateway and admin dashboard |
| 443 | udp | HTTP/3 for the same traffic |
| 7881 | tcp | LiveKit media, published by the LiveKit container |
| 7882 | udp | LiveKit media, published by the LiveKit container |
A machine behind a home router needs ports 80, 443 and the two LiveKit ports forwarded to it, because the certificate is issued from the public internet and voice media arrives on the LiveKit ports directly.
Behind your own reverse proxy the proxy holds 80 and 443 and the stack binds a plain HTTP port on the loopback. 7881 and 7882 still have to reach the host, because voice media never goes through a proxy.
On a cloud VM, open them in the provider's firewall or security group. On a Linux host running firewalld:
```bash
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-port=80/tcp --add-port=443/tcp --add-port=443/udp \
--add-port=7881/tcp --add-port=7882/udp
sudo firewall-cmd --reload
```
## Step 4: Bring up the instance
The installer downloads the stack files, writes `.env`, generates every secret, and starts the instance. Download it, check the published SHA-256, read it, then run it.
On Linux:
```bash
curl -fsSLO https://fluxer.dev/install.sh
curl -fsSLO https://fluxer.dev/install.sh.sha256
sha256sum -c install.sh.sha256
```
On macOS, the same two downloads and:
```bash
shasum -a 256 -c install.sh.sha256
```
On Windows:
```powershell
Invoke-WebRequest https://fluxer.dev/install.ps1 -OutFile install.ps1
Invoke-WebRequest https://fluxer.dev/install.ps1.sha256 -OutFile install.ps1.sha256
(Get-FileHash .\install.ps1 -Algorithm SHA256).Hash -eq (Get-Content .\install.ps1.sha256).Split()[0]
```
`install.sh: OK` on Linux and macOS, and `True` on Windows, mean the file arrived whole. The digest of the copy this site serves is <InstallerChecksum script="install.sh" /> for `install.sh` and <InstallerChecksum script="install.ps1" /> for `install.ps1`.
Read the script before you run it. It writes `.env`, which holds every secret the instance has.
The run below uses the bundled TLS. Add `--tls proxy` when something else terminates TLS, and read [Behind your own reverse proxy](/operator/reverse-proxy/) first.
Then run it. The hostname is the one from Step 2, and `--email` becomes the contact address on outgoing web push:
```bash
sh install.sh --domain chat.example.com --email [email protected]
```
On Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\install.ps1 -Domain chat.example.com -Email [email protected]
```
The run prints one line per phase and ends with the URL to open. It takes several minutes, most of it spent pulling eighteen images.
`~/fluxer` then holds `docker-compose.yml`, `docker-compose.proxy.yml`, `tunnel.compose.yml`, `Caddyfile` and `.env.example`, plus a `.env` of 20 lines readable only by you. Every value that ships as `CHANGE_ME` in `.env.example` is a fresh random value. Every command from here on runs in that directory.
### Installer flags
| Flag | Meaning |
| --- | --- |
| `--domain <host>` | Hostname the instance answers on. Prompted when absent |
| `--email <address>` | Contact address written as `FLUXER_VAPID_EMAIL`. Prompted when absent |
| `--dir <path>` | Working directory. Default `~/fluxer` |
| `--ref <git ref>` | Ref the stack files come from. Defaults to the image tag, and to `main` when that tag is `v1` or `latest` |
| `--image-tag <tag>` | Image tag the stack runs. Default `v1` |
| `--tls <mode>` | `bundled` or `proxy`. Default `bundled` |
| `--edge-bind <addr:port>` | Plain HTTP bind under `--tls proxy`. Default `127.0.0.1:8080` |
| `--non-interactive` | Never prompt. A missing required value is an error |
| `--dry-run` | Print the plan. Write nothing and start nothing |
| `--no-start` | Write everything and skip `docker compose up -d` |
| `--update` | Upgrade: record, back up, refresh, pull, recreate, verify |
| `--rollback` | Put back the images and stack files of the newest record |
| `--allow-root` | Permit running as root |
Run `--dry-run` first to see what a set of flags does. The PowerShell script takes the same flags as `-Domain`, `-Email` and so on, and has no `-AllowRoot`. [Upgrading](/operator/upgrading/#run-the-upgrade) has the flags that only `--update` and `--rollback` read, and the exit codes.
### The script is the reference
Each step in the script has a comment beside the code. The comment holds the command that does that step alone, and the reason the step exists: the download URL for every stack file, how `.env` is built from `.env.example`, what each `CHANGE_ME` takes, and how the VAPID pair is derived. Read it at [https://fluxer.dev/install.sh](https://fluxer.dev/install.sh), or the Windows script at [https://fluxer.dev/install.ps1](https://fluxer.dev/install.ps1).
## Step 5: Check that it works
Watch the stack settle:
```bash
cd ~/fluxer
docker compose ps
docker compose logs -f api
```
Every service reads `running` or `healthy` except `seaweedfs-init`, which creates the five upload buckets and then reads `exited (0)`.
Then the public probes. Replace the hostname with your own:
```bash
base=https://chat.example.com
for p in /_health /api/_health /gateway/_health /media/_health /.well-known/fluxer /; do
printf '%s %s\n' "$(curl -sS -o /dev/null -w '%{http_code}' "$base$p")" "$p"
done
```
On Windows:
```powershell
$base = 'https://chat.example.com'
foreach ($p in '/_health', '/api/_health', '/gateway/_health', '/media/_health', '/.well-known/fluxer', '/') {
'{0} {1}' -f (Invoke-WebRequest "$base$p" -UseBasicParsing).StatusCode, $p
}
```
All six return 200. `/.well-known/fluxer` lists the endpoints clients use, with your own hostname in them.
<a id="first-run"></a>
## Step 6: Create the first admin
Open the hostname in a browser. A fresh instance is unconfigured, so it serves the setup wizard.
The wizard runs in two halves. Before you are signed in it walks a welcome step, a theme choice, an admin introduction, and an account form. After that account exists it walks branding, registration mode, community policy and media expiry, then the GIF, YouTube, captcha, email and Bluesky integrations, then the service toggles, the premium model and a finish step. Finishing marks the instance configured and takes the wizard down.
Create the owner account with an email address at a domain you control. On a self-hosted instance the first registration that supplies an email address receives the wildcard admin ACL, which is full access to the admin dashboard, unless registration is set to hold new accounts for approval. Finishing the wizard also grants the wildcard ACL to the account that completes it when that account holds no admin ACL yet.
`.env.example` ships `FLUXER_EMAIL_ENABLED=false`, which marks every address verified at creation and sends no mail at all. Nothing goes out by mail, so a forgotten owner password has no email reset. Record it, and register a passkey or a second admin account before you open registration.
Then sign in to the admin dashboard at `https://chat.example.com/admin` with the account holding the wildcard ACL. The **Instance Config** page has everything the wizard asked, plus registration mode, approvals and integration keys. **Limit Config** holds the instance limits published to clients. **Voice Regions** and **Voice Servers** come seeded, so voice needs no setup there.
The desktop client opens the hosted web app for its release channel, so reach your instance through the web app. [Issue #1088](https://github.com/fluxerapp/fluxer/issues/1088) tracks progress.
[Configuration](/operator/configuration/) lists every runtime setting and the environment variable each one overrides. [Deployment availability](/http-api/deployment-availability/) lists the routes that exist only on the hosted deployment.
## Backups
An instance comes back from two artifacts and its `.env`: a dump of the database, which covers `postgres-data`, and a tarball of `seaweedfs-data`, which holds every upload, avatar, report and harvest. [Volumes and buckets](/operator/configuration/#volumes-and-buckets) lists all seven volumes with what each one holds.
The dump costs no downtime, so run it on a schedule while the stack serves:
```bash
cd ~/fluxer
mkdir -p backups
docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom \
> "backups/fluxer-$(date -u +%Y%m%dT%H%M%SZ).dump"
```
PowerShell writes UTF-16 through `>`, which corrupts a binary dump. On Windows, write the dump inside the container and copy it out:
```powershell
mkdir -Force backups
docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom -f /tmp/fluxer.dump
docker compose cp postgres:/tmp/fluxer.dump backups/fluxer.dump
docker compose exec -T postgres rm /tmp/fluxer.dump
```
The uploads copy stops the stack, because a live copy can catch a file mid-write. Compose prefixes volume names with the project name, which the stack sets to `fluxer`:
```bash
docker compose stop
docker run --rm -v fluxer_seaweedfs-data:/data -v "$PWD/backups:/backup" alpine:3.22 \
tar czf /backup/seaweedfs-data.tgz -C /data .
docker compose up -d
```
In PowerShell write `${PWD}` in place of `$PWD`.
A dump and a tarball in `backups`, and every service back to `running`, is the success signal.
`sh install.sh --update` takes both before every upgrade. [What the backup covers](/operator/upgrading/#what-the-backup-covers) puts the dump on a nightly timer, and [Restore a backup](/operator/upgrading/#restore-a-backup) puts either artifact back.
### Remove the instance
Take the instance down and delete its data:
```bash
docker compose down -v
```
`-v` deletes all seven named volumes, so every account, message, upload, search index and certificate the instance holds is gone, and no `docker compose up -d` brings them back. Take the copies above first.
## Upgrading
The default tag `v1` tracks the latest compatible release. The same script upgrades the instance:
```bash
cd ~/fluxer
sh install.sh --update
```
`--update` records the running images, backs up the database and the uploads, refreshes the four stack files, pulls, recreates, and verifies. It leaves every secret in `.env` untouched. On Windows it is `.\install.ps1 -Update`. Run `sh install.sh --update --dry-run` first to see the plan.
[Upgrading](/operator/upgrading/) covers what the backup holds, rolling back, pinning a release and reclaiming disk.
## Getting help
File issues and follow development on [GitHub](https://github.com/fluxerapp/fluxer).
@@ -0,0 +1,490 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Behind your own reverse proxy
description: Serving a Fluxer instance from nginx, Caddy, Traefik, HAProxy, Apache, or a tunnel.
---
:::tip[Plutonium and donations fund Fluxer]
We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
:::
A reverse proxy takes the HTTPS connection from a browser and passes each request on to Fluxer. Fluxer runs behind any proxy that terminates TLS and forwards WebSocket upgrades.
The stack ships Caddy and runs it by default on `80` and `443`. One extra Compose file stops that and publishes a single plain HTTP port instead. That port already routes every path to the right internal service, so the proxy in front needs one routing rule: send everything to it.
## Enable the overlay
`docker-compose.proxy.yml` ships next to `docker-compose.yml` and layers on top of it. Pass both files on every command:
```bash
docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
```
Or set `COMPOSE_FILE` in `.env` once and keep typing plain `docker compose` commands:
```ini
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
```
On a first install, `sh install.sh --tls proxy` writes that line and `FLUXER_EDGE_BIND` into `.env` for you, so there is nothing to add by hand. [Installer flags](/operator/get-started/#installer-flags) lists it with the rest.
The port binds `127.0.0.1:8080` by default. `FLUXER_EDGE_BIND` sets both the interface and the host port. Move the port when something else on the host already holds `8080`:
```ini
FLUXER_EDGE_BIND=127.0.0.1:8081
```
The container side stays `8080` whatever you set. A proxy on the host then points at `127.0.0.1:8081`, and a proxy on the stack's own Docker network points at `edge:8080`. Bind a routable address only when the proxy runs on another machine, and firewall the port to that machine:
```ini
FLUXER_EDGE_BIND=0.0.0.0:8080
```
Confirm the port answers before you configure anything in front of it:
```bash
curl -i http://127.0.0.1:8080/_health
```
`/_health` returns `200 OK` from the edge itself without reaching an upstream, so it is the health check to give your proxy.
:::note[There is nothing to configure inside the instance]
The routing lives in a config file inside the stack. Point your proxy at the port and leave that file alone.
:::
## What every proxy must do
#### Terminate TLS and serve the hostname over HTTPS
With `FLUXER_PUBLIC_SCHEME=https` the admin CSRF cookie is `__Host-csrf_token`, which browsers accept only over HTTPS.
#### Forward WebSocket upgrades
The client connection is on `/gateway` and voice signalling is on `/livekit/*`. Both open as WebSocket upgrades. Check that the handshake completes through whatever is in front of the instance, with your own hostname in place of the one below:
```bash
curl -sS -i --http1.1 --max-time 5 \
-H 'Connection: Upgrade' \
-H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: AAAAAAAAAAAAAAAAAAAAAA==' \
'https://chat.example.com/gateway?v=1&encoding=json' | head -1
```
The answer is `HTTP/1.1 101 Switching Protocols`. Confirm a real client connects as well, which covers the whole path including the query string.
#### Replace `X-Forwarded-For` with the real client address
Set the header from the connection address and discard whatever the visitor sent. Rate limits, IP bans and abuse detection all apply to the address that arrives in it.
#### Accept a request body above the attachment limit
Uploads pass through the proxy. Raise any default body limit, such as nginx's 1 MB, above the instance attachment limit.
#### Hold a connection open for an hour
The Gateway socket stays open for the life of a client session, so give idle and read timeouts an hour.
#### Pass `Sec-Fetch-Site` through untouched
The browser sends it, and the admin dashboard refuses a mutating request with a cross-site value.
#### Send no `Content-Security-Policy` of its own
The instance sends its own policy, and its nonce is what lets the web app boot. Browsers enforce every policy they receive.
Every worked configuration below answers all seven.
## The public address
Three variables tell the instance the address browsers use. Fluxer reads neither `X-Forwarded-Proto` nor `X-Forwarded-Host`, so you keep these correct by hand:
```ini
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
```
They stay `https` on `443` even though the instance itself speaks plain HTTP on `8080`. Clients read every base URL from the discovery document the API builds out of these values.
Serving on a port other than `443` also needs `FLUXER_PUBLIC_ORIGIN`. Several endpoints are built from the scheme and the domain alone, and the port is lost without it:
```ini
FLUXER_PUBLIC_ORIGIN=https://chat.example.com:8443
```
## nginx
The `map` block makes `Connection` follow `Upgrade`. The snippet raises the body limit and the read timeout above nginx's own 1 MB and 60 seconds.
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name chat.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name chat.example.com;
ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
client_max_body_size 512m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_request_buffering off;
proxy_buffering off;
}
}
```
Set `X-Forwarded-For` from `$remote_addr`. `$proxy_add_x_forwarded_for` appends whatever the client sent, which puts an attacker-chosen address in front of the real one.
## Caddy
An external Caddy needs one site block. It forwards WebSocket upgrades natively, requests the certificate itself, and applies no request body limit and no read timeout of its own.
```caddy
chat.example.com {
reverse_proxy 127.0.0.1:8080 {
header_up X-Forwarded-For {client_ip}
}
}
```
`{client_ip}` resolves to the peer address unless the peer is in this Caddy's own `trusted_proxies`. The `header_up` line replaces `X-Forwarded-For` with that one address.
## Traefik
Traefik forwards WebSocket upgrades natively, and by default it strips inbound `X-Forwarded-*` headers from untrusted clients and writes its own. Leave `forwardedHeaders` alone unless another proxy sits in front of Traefik.
### File provider
Static configuration:
```yaml
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: 0s
idleTimeout: 3600s
providers:
file:
filename: /etc/traefik/dynamic.yml
certificatesResolvers:
letsencrypt:
acme:
email: [email protected]
storage: /etc/traefik/acme.json
httpChallenge:
entryPoint: web
```
`readTimeout` defaults to 60 seconds and covers reading the whole request including the body, so `0s` removes the bound and lets a large upload finish. `idleTimeout` defaults to 180 seconds, so raise it to an hour to hold the Gateway socket open.
Dynamic configuration in `/etc/traefik/dynamic.yml`:
```yaml
http:
routers:
fluxer:
rule: Host(`chat.example.com`)
entryPoints:
- websecure
service: fluxer
tls:
certResolver: letsencrypt
services:
fluxer:
loadBalancer:
passHostHeader: true
servers:
- url: http://127.0.0.1:8080
```
### Docker labels
Traefik reaches the container over a shared Docker network, so the published port is not used. Keep the overlay applied anyway, or the instance takes `80` and `443` away from Traefik.
Put this in `traefik.compose.yml` next to the stack:
```yaml
services:
edge:
networks:
- fluxer
- traefik
labels:
traefik.enable: "true"
traefik.docker.network: traefik
traefik.http.routers.fluxer.rule: Host(`chat.example.com`)
traefik.http.routers.fluxer.entrypoints: websecure
traefik.http.routers.fluxer.tls.certresolver: letsencrypt
traefik.http.services.fluxer.loadbalancer.server.port: "8080"
networks:
traefik:
external: true
```
Then list all three files so every command picks them up:
```ini
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml:traefik.compose.yml
```
## HAProxy
One frontend, one backend, and four timeouts:
```haproxy
defaults
mode http
log stdout format raw local0
option httplog
timeout connect 5s
timeout client 1h
timeout server 1h
timeout tunnel 1h
frontend fluxer
bind :80
bind :443 ssl crt /etc/haproxy/certs/chat.example.com.pem alpn h2,http/1.1
http-request redirect scheme https unless { ssl_fc }
http-request set-header X-Forwarded-For %[src]
default_backend fluxer_edge
backend fluxer_edge
server edge 127.0.0.1:8080
```
`timeout client` and `timeout server` stop applying once a connection is upgraded, so `timeout tunnel` is what keeps the Gateway socket and LiveKit signalling alive. `set-header` replaces any header the client sent. `option forwardfor` appends to it. Give the frontend and the backend different names. HAProxy warns on a shared name and drops support for it in 3.3.
## Apache httpd
This configuration needs three modules: `mod_proxy`, `mod_proxy_http`, and `mod_headers`. Since 2.4.47 `mod_proxy_http` handles the WebSocket upgrade itself, so `mod_proxy_wstunnel` is not needed.
```apache
<VirtualHost *:443>
ServerName chat.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/chat.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/chat.example.com/privkey.pem
LimitRequestBody 536870912
ProxyPreserveHost On
ProxyTimeout 3600
RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"
ProxyPass / http://127.0.0.1:8080/ upgrade=websocket timeout=3600
ProxyPassReverse / http://127.0.0.1:8080/
</VirtualHost>
```
`upgrade=websocket` is the whole WebSocket configuration on 2.4.47 and later.
`RequestHeader set` replaces whatever the client sent, and the quotes around `"expr=%{REMOTE_ADDR}"` are required by the expression parser. `ProxyAddHeaders` is on by default and appends the connection address after that value. The edge reads the real address either way.
`LimitRequestBody` is 0 by default, which means no limit, but distribution packages and `<Directory>` blocks often set it lower. The 536870912 above is 512 MiB. `ProxyTimeout` and the `timeout=3600` parameter both bound the upstream connection, and an hour covers the Gateway socket.
## Cloudflare Tunnel
One ingress rule for the hostname, with the catch-all `cloudflared` requires under it:
```yaml
tunnel: fluxer
credentials-file: /etc/cloudflared/fluxer.json
ingress:
- hostname: chat.example.com
service: http://127.0.0.1:8080
originRequest:
connectTimeout: 30s
- service: http_status:404
```
When `cloudflared` runs as a container on the stack's Docker network, point it at `http://edge:8080`. The published port is then never used. Compose prefixes the network name with the project name, which the stack sets to `fluxer`, so the network is `fluxer_fluxer` and an external declaration names it in full:
```yaml
networks:
fluxer:
external: true
name: fluxer_fluxer
```
Cloudflare appends the visitor address to any `X-Forwarded-For` the visitor sent, so the header can arrive with a forged value in front of the real one. The edge reads the rightmost address that is not in `FLUXER_EDGE_TRUSTED_PROXIES`, which is the one Cloudflare appended. A Transform Rule that sets `X-Forwarded-For` to `cf.connecting_ip` removes the ambiguity outright.
Cloudflare caps a request body at 100 MB on Free and Pro, 200 MB on Business, and 500 MB on Enterprise, and answers 413 above the cap. The cap applies to tunnel traffic, and no `cloudflared` setting raises it. Keep the [`max_attachment_file_size`](/http-api/instance/#limit-keys) limit below the cap for your plan, in the admin dashboard under limit configuration. It defaults to 26214400 bytes for a non-premium account and 524288000 bytes for a premium one, which is above every cap below Enterprise.
A tunnel has the web app, API, Gateway, admin dashboard, media routes, and LiveKit signalling. It has no LiveKit media.
## Nginx Proxy Manager
Add a proxy host with these values:
| Field | Value |
| --- | --- |
| Domain Names | The hostname `chat.example.com` |
| Scheme | Plain `http` |
| Forward Hostname / IP | `127.0.0.1`, or the `edge` container when both run in Docker |
| Forward Port | Port `8080` |
| Websockets Support | On, and required |
| Block Common Exploits | Off, because its request filtering rejects legitimate API traffic |
| SSL | Request a certificate, then turn on Force SSL and HTTP/2 Support |
In the Advanced tab:
```nginx
client_max_body_size 512m;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
```
Nginx Proxy Manager appends to `X-Forwarded-For`, and the visitor's own value arrives in front of the real address. The edge takes the rightmost entry that is not in `FLUXER_EDGE_TRUSTED_PROXIES`, which is the one Nginx Proxy Manager appended. Users on a LAN or a VPN inside the default `private_ranges` are the exception and resolve to the forged entry, so narrow `FLUXER_EDGE_TRUSTED_PROXIES` to the proxy's address in that layout.
## What the single port routes
The instance rewrites paths before handing them to an upstream. Your proxy must not rewrite anything itself.
#### `/_health`
No upstream. Answered by the edge itself.
#### `/gateway?v=1&encoding=json&compress=zstd-stream&stream=1`
Goes to `gateway:8080` and reaches the upstream as `/?v=1&encoding=json&compress=zstd-stream&stream=1`.
#### `/gateway/foo`
Goes to `gateway:8080` and reaches the upstream as `/foo`.
#### `/api/v1/users/@me`
Goes to `api:8080` and reaches the upstream as `/v1/users/@me`.
#### `/media/attachments/1/2/a.png`
Goes to `media-proxy:8080` and reaches the upstream as `/attachments/1/2/a.png`.
#### `/livekit/rtc/v1`
Goes to `livekit:7880` and reaches the upstream as `/rtc/v1`.
#### `/admin`
Goes to `admin:8080` and reaches the upstream as `/`.
#### `/admin/users`
Goes to `admin:8080` and reaches the upstream as `/users`.
#### Static asset paths
`/web/*`, `/emoji/*`, `/libs/*`, `/avatars/*`, `/badges/*`, `/desktop/*` and `/embeds/*` go to `static-proxy:8080` unchanged.
#### `/.well-known/fluxer`
Goes to `api:8080` unchanged.
#### Apple app site association
`/.well-known/apple-app-site-association`, and `/apple-app-site-association` for older iOS, go to `app-proxy:8080` unchanged.
#### `/.well-known/assetlinks.json`
Goes to `app-proxy:8080` unchanged.
#### `/version.json`
Goes to `app-proxy:8080` unchanged.
#### Anything else
Goes to `app-proxy:8080` unchanged.
Pass the query string on `/gateway` through untouched. Clients always send `?v=`, `?encoding=`, `?compress=` and `?stream=`, and `1` is the only version the Gateway accepts.
Apple and Google fetch the two association files themselves at those fixed paths, for saved-password autofill in the iOS apps and link handling in the Android ones. The last entry already has all four, so a proxy that forwards `/` needs no extra rule. A proxy that forwards a named path allowlist has to list these four paths along with everything else that entry covers.
`/_metrics` on the API, Media Proxy, and Gateway, plus `/_health/ready`, `/_health/drain`, and `/_health/undrain` on the Gateway, are gated to loopback and are unreachable through any proxy. The probes that work through a proxy are `/_health`, `/api/_health`, `/gateway/_health`, and `/media/_health`.
## Trusted proxies
`FLUXER_EDGE_TRUSTED_PROXIES` names the peer addresses whose `X-Forwarded-For` the edge believes. It defaults to `private_ranges`, which Caddy expands to `192.168.0.0/16`, `172.16.0.0/12`, `10.0.0.0/8`, `127.0.0.1/8`, `fd00::/8`, and `::1`, so a proxy on the same host or the same Docker network needs no change.
The edge resolves one client address per request and rewrites `X-Forwarded-For` to it on every upstream hop, so no service ever reads what a visitor sent. A peer outside the list becomes the client address, and the edge discards the header it sent. A peer inside the list contributes the rightmost header entry that is not itself trusted, and a proxy that appends still delivers the real caller.
A proxy that reaches the instance from a public address is not trusted, so the edge discards its header and attributes every request to the proxy itself. That puts all your users in one rate limit bucket and one geolocation. Add the address, and list `private_ranges` too if you still need it:
```ini
FLUXER_EDGE_TRUSTED_PROXIES=private_ranges 203.0.113.10/32
```
`private_ranges` does not cover `100.64.0.0/10`, so a proxy reaching the instance over a Tailscale or other CGNAT tailnet address is untrusted and collapses every visitor onto that one address. Name the tailnet address of the proxy:
```ini
FLUXER_EDGE_TRUSTED_PROXIES=private_ranges 100.101.102.103/32
```
The edge believes a trusted peer without question. Anyone who can open a TCP connection from a trusted address can set `X-Forwarded-For` to any value and be recorded as that address, which defeats bans and rate limits and pollutes abuse detection. Keep the list down to the one address your proxy arrives from. That is also the only correct list on a LAN or VPN deployment, where a visitor whose own address falls inside a wider list is recorded under the proxy's address.
Find the address the edge sees a host-side proxy arrive from:
```bash
docker network inspect fluxer_fluxer -f '{{(index .IPAM.Config 0).Gateway}}'
```
The edge reads the variable at container start, so apply a change with `docker compose up -d`.
:::caution[Keep the plain HTTP port off the LAN]
`private_ranges` trusts the whole LAN. With `FLUXER_EDGE_BIND=0.0.0.0:8080`, any host on that network can impersonate any client address. Firewall the port to the proxy host.
:::
## Voice media does not use the proxy
LiveKit signalling goes through `/livekit/*` like everything else. WebRTC media does not touch the proxy at all:
- `7882/udp` is media.
- `7881/tcp` is the fallback when UDP is blocked.
Both ports are published directly by the stack and must reach the host. A proxy or tunnel in front of `443` does nothing for them.
Hosting LiveKit on a hostname other than `FLUXER_DOMAIN` means widening the Content-Security-Policy the web app runs under:
```ini
FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
```
@@ -0,0 +1,237 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Upgrading
description: Upgrading and rolling back an instance with the installer, what the backup covers, and how to restore it.
---
:::tip[Plutonium and donations fund Fluxer]
We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
:::
An upgrade moves an instance to a newer release. `install.sh --update` does all of it: back up, download the new stack files, pull the new images, recreate the containers that changed. The stack files are the three compose files, the `Caddyfile` and `.env.example`. Allow about twenty minutes, most of it the backup and the pull.
| Requirement | Value |
| --- | --- |
| Working directory | The one holding `.env`, `docker-compose.yml`, `docker-compose.proxy.yml`, `tunnel.compose.yml` and `Caddyfile` |
| Compose commands | Behind your own reverse proxy, set `COMPOSE_FILE` in `.env` once and every command picks up the overlay |
| Free disk | Room for one database dump, one copy of the uploads, and the new images beside the old ones |
| Downtime | A minute or two for the recreate, plus however long the uploads copy takes with the stack stopped |
Read the release notes between the version you run and the one you are moving to, at [Releases](https://github.com/fluxerapp/fluxer/releases).
## Match the images to the stack files
The images come from `FLUXER_IMAGE_TAG` in `.env`. The stack files come from a git ref, which is a branch or a tag in the Fluxer repository. The installer derives that ref from the image tag: `main` when the tag is `v1` or `latest`, and the tag string itself for anything else. `--ref` overrides the derivation. `docker compose pull` never updates a stack file, and refreshing a stack file never moves an image.
The repository holds no ref by the name of a pinned image tag. A release tags each image on its own, as `[email protected]`, so pass `--ref` with that tag or with the commit it points at. Without that override the download fails with exit 4.
## What the script does
`--update` runs these steps in this order, and stops on the first one that fails:
| Step | What it does |
| --- | --- |
| Record | Writes the image references, the image ID each container has, and the tag `.env` names, into a new record directory |
| Save | Copies `.env` and the five stack files into that record |
| Dump | Dumps the database with the stack still serving, and checks the custom-format header on the result |
| Copy | Stops the stack, copies the uploads volume, and starts it again on the images it was already running |
| Fetch | Downloads the stack files at the ref into a staging directory inside the working directory |
| Guard | Refuses when the refreshed `docker-compose.yml` moves Postgres to a new major version |
| Place | Moves every downloaded file into the working directory at once |
| Pull | Pulls the images while the old containers still serve |
| Recreate | Runs `docker compose up -d --remove-orphans`, then restarts `edge` when the `Caddyfile` changed |
| Verify | Polls Compose state until every service is ready, then probes `/_health` on the hostname |
A failed run leaves the instance running on the images it already had.
`docker compose up -d` does not notice a changed `Caddyfile`. The script restarts `edge` by name to pick it up.
The refreshed `docker-compose.yml` renames the `caddy` service to `edge`, and the two publish the same host ports. `--remove-orphans` takes the old container down in the same call, so the new one can bind them. Without it the step stops with `Bind for 0.0.0.0:443 failed`. It also removes any container in the project whose service the loaded Compose files no longer define, so keep `COMPOSE_FILE` the same across an upgrade.
The rename also moves the `caddy-data` and `caddy-config` volumes to `edge-data` and `edge-config`, so the old two are left unused and the edge requests its certificate again on the first start.
`FLUXER_IMAGE_TAG` is the only line in `.env` that either upgrade mode writes. Every secret is left as it is.
The refreshed `docker-compose.yml` requires `FLUXER_ERLANG_COOKIE`. An `.env` written by an earlier installer has no such line, so add `FLUXER_ERLANG_COOKIE=$(openssl rand -hex 32)` to it before you upgrade. The value is 64 hex characters, which is what a fresh install writes. Until `.env` sets it, every Compose command against the stack stops with `set FLUXER_ERLANG_COOKIE in .env`.
## The script is the reference
Every step above sits in the script beside a comment holding the command that does that step alone and the reason the step exists. Read [https://fluxer.dev/install.sh](https://fluxer.dev/install.sh), or [https://fluxer.dev/install.ps1](https://fluxer.dev/install.ps1) for Windows.
## Run the upgrade
Keep the installer beside the stack files. [Get started](/operator/get-started/#step-4-bring-up-the-instance) has the download and the checksum check for a fresh copy.
See the plan first:
```bash
cd ~/fluxer
sh install.sh --update --dry-run
```
It prints the images the stack runs now, the backup it intends to take, which stack files the ref changes, which services that makes it restart, and any refusal that would stop the run. It writes nothing outside a temporary directory it removes on exit.
Then run it:
```bash
sh install.sh --update
```
On Windows:
```powershell
.\install.ps1 -Update
```
The run ends by naming the record it wrote and the command that rolls back to it. It checks its own work with the readiness poll and one probe of `/_health`. Run the public probes in [Check that it works](/operator/get-started/#step-5-check-that-it-works) afterwards.
| Flag | Windows | Meaning |
| --- | --- | --- |
| `--update` | `-Update` | Record, back up, refresh, pull, recreate, verify |
| `--rollback` | `-Rollback` | Put back the images and stack files of the newest record |
| `--dry-run` | `-DryRun` | Print the plan. Change nothing |
| `--backup-dir <path>` | `-BackupDir` | Where records go. Default `<dir>/backups` |
| `--no-volume-backup` | `-NoVolumeBackup` | Take the database dump and skip the uploads copy |
| `--skip-backup-accept-data-loss` | `-SkipBackupAcceptDataLoss` | Upgrade with no backup at all |
`--dir` and `--ref` mean what they mean on an install, and the installer derives an omitted `--ref` from the `FLUXER_IMAGE_TAG` line in `.env`. Every failure prints a sentence on standard error and exits non-zero, and the script header lists what each code means. A Postgres major version change is exit 3.
## What the backup covers
Each upgrade writes one record directory, named `record-` and a UTC stamp, under `backups` or wherever `--backup-dir` points. It holds:
| Artifact | What it covers |
| --- | --- |
| `fluxer.dump` | Every account, message, guild and configuration row, in the Postgres custom format |
| `seaweedfs-data.tgz` | Every upload, avatar, report and harvest |
| `.env` | Every secret the instance was built with |
The five stack files sit in the record beside those three, so a rollback puts back the exact files the instance was running. The record directory is created `0700` and the `.env` copy inside it is `0600`. Keep records wherever you already keep secrets.
The dump costs no downtime. The uploads copy stops the stack, and the script starts the stack again on the old images before it goes any further.
Nothing else is copied. The dump covers `postgres-data`. The other five volumes either rebuild themselves or hold queued work, and [Volumes and buckets](/operator/configuration/#volumes-and-buckets) lists all seven with what each one holds.
Put a dump on a timer as well. On Linux and macOS, this crontab line writes one a night and keeps two weeks of them:
```cron
15 3 * * * cd /srv/fluxer && docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom > "backups/fluxer-$(date -u +\%Y\%m\%dT\%H\%M\%SZ).dump" && find backups -name 'fluxer-*.dump' -mtime +14 -delete
```
`/srv/fluxer` stands for the directory holding `.env`. Write it as an absolute path, because cron does not expand `~`. An unescaped `%` in a crontab is a newline, which is why every one of them has a backslash.
## Roll back
A rollback puts the previous release back:
```bash
cd ~/fluxer
sh install.sh --rollback
```
On Windows it is `.\install.ps1 -Rollback`. It takes the newest record, puts back the images and the stack files it holds, recreates, restarts `edge`, and verifies. `--dry-run` prints that plan too.
A rollback never pulls, so it needs the old images still on the host. Run [Reclaim disk](#reclaim-disk) once an upgrade satisfies you and not before.
The database does not move. `api`, `worker`, `users-shard` and `messages-shard` apply schema work in place while they start, and an older image does not undo it. Across a release that changed the schema, the dump in the record is the only way back, and putting it back is a decision you make.
## Restore a backup
Both artifacts go back with the stack stopped, so nothing writes while they are replaced. Name your own record directory in place of the one below.
The database restores from the custom-format dump:
```bash
docker compose stop
docker compose up -d --wait postgres
docker compose exec -T postgres pg_restore -U fluxer -d fluxer --clean --if-exists \
< backups/record-20260831T120000Z/fluxer.dump
docker compose up -d
```
`--clean` prints notices about objects that do not exist yet, which is expected against a fresh directory.
PowerShell has no `<` redirection. On Windows, copy the dump into the container and name it as a file:
```powershell
docker compose stop
docker compose up -d --wait postgres
docker compose cp backups\record-20260831T120000Z\fluxer.dump postgres:/tmp/fluxer.dump
docker compose exec -T postgres pg_restore -U fluxer -d fluxer --clean --if-exists /tmp/fluxer.dump
docker compose exec -T postgres rm /tmp/fluxer.dump
docker compose up -d
```
The uploads restore through the same helper container the backup used, in reverse:
```bash
docker compose stop
docker run --rm -v fluxer_seaweedfs-data:/data \
-v "$PWD/backups/record-20260831T120000Z:/backup" alpine:3.22 \
sh -c 'find /data -mindepth 1 -delete && tar xzf /backup/seaweedfs-data.tgz -C /data'
docker compose up -d
```
`tar` extracts over whatever is already on the volume, so the `find` empties it first. In PowerShell write `${PWD}` in place of `$PWD`. Success is an existing attachment URL answering 200 again. The `fluxer_` prefix is the Compose project name, which `docker-compose.yml` sets to `fluxer`.
## Move to a new Postgres major version
`docker-compose.yml` pins `postgres:16-alpine`. A newer major does not read the data directory an older major wrote, so the data moves across through a dump. The upgrade refuses when the refreshed compose file changes that pin, because the move destroys the volume holding the database.
Take the dump while the old major is still serving, then stop everything and drop the volume:
```bash
docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom \
> backups/pre-major.dump
docker compose down
docker volume rm fluxer_postgres-data
```
On Windows, take that dump the way [Restore a backup](#restore-a-backup) moves one, with `-f /tmp/pre-major.dump` and `docker compose cp`, and restore it the same way.
Once that volume is removed, the dump is the only copy of the database.
Put the new `postgres` tag in `docker-compose.yml`, start the database on its own, and restore into the empty directory:
```bash
docker compose up -d --wait postgres
docker compose exec -T postgres pg_restore -U fluxer -d fluxer --clean --if-exists \
< backups/pre-major.dump
docker compose up -d
```
Run `sh install.sh --update` afterwards to finish the move on the refreshed files.
## Pin a release
`FLUXER_IMAGE_TAG=v1` tracks the latest compatible release, so every upgrade can move the instance. Set it to a release tag to hold one version and upgrade on your own schedule.
A pinned instance also pins its stack files. [Match the images to the stack files](#match-the-images-to-the-stack-files) has the `--ref` a pinned tag needs.
The tag applies only to the eleven Fluxer images. `caddy`, `postgres`, `valkey`, `nats`, `meilisearch`, `seaweedfs`, and `livekit` are pinned inside `docker-compose.yml` and move only when an upgrade refreshes that file.
## Change the domain or the passkey relying party
`FLUXER_DOMAIN` supplies the default `FLUXER_PASSKEY_RP_ID`, which is the domain a passkey is tied to. A passkey works only under the identifier it was created with.
:::danger[Changing either value invalidates every passkey]
Passkeys registered under the old `FLUXER_PASSKEY_RP_ID` stop working and cannot be recovered. Every user has to register a new one, so keep another sign-in method working before you change it.
:::
Changing `FLUXER_DOMAIN` also changes the certificate the edge requests, the CORS origins the API accepts, and the admin OAuth redirect URI.
## Reclaim disk
Each upgrade leaves the previous images behind, which adds up to a few gigabytes over time:
```bash
docker image prune -f
```
On the default moving tag, the pull moves `v1` onto the new images and leaves the ones it replaced untagged, so this removes them. Those are the images a rollback on a moving tag needs, so prune only once the upgrade satisfies you. A pinned release keeps its own tag and survives the prune. Remove one of those by naming its tag in `docker image rm`.
`docker system prune -a` reclaims more. It also deletes every image that no container references, including ones unrelated to Fluxer.
:::danger[Never add -a to a volume prune]
`docker volume prune -a` removes named volumes no container uses. After a `docker compose down` that is every volume the instance has, which is the whole instance.
:::