Appearance
Manual Setup
Full control over every configuration option. For developers and sysadmins.
Prerequisites
- Docker and Docker Compose v2
- A domain with an A record pointing to your server
- Ports 80 and 443 open
1. Download and configure
The node ships as a container image — you do not need the source.
bash
mkdir dissent-node && cd dissent-node
BASE=https://docs.dissent.chat/install
curl -fsSL $BASE/docker-compose.yml -o docker-compose.yml
curl -fsSL $BASE/Caddyfile -o Caddyfile
curl -fsSL $BASE/env.example -o .envCompose pulls ghcr.io/ikrowni/dissent-node:latest by default. Pin a version by setting DISSENT_IMAGE in .env:
bash
DISSENT_IMAGE=ghcr.io/ikrowni/dissent-node:v1.0.0Edit .env with your values. Required fields:
| Variable | Description | Example |
|---|---|---|
JWT_SECRET | Node secret. Must be at least 32 characters and not the placeholder — the node refuses to start otherwise. openssl rand -hex 32 | a3f9... |
NODE_ID | Unique identifier for your node — use your domain | node.example.com |
NODE_URL | Public HTTPS URL of your node | https://node.example.com |
CADDY_DOMAIN | Domain Caddy obtains a TLS certificate for | node.example.com |
LIVEKIT_API_KEY | LiveKit API key — use openssl rand -hex 8 | abc123 |
LIVEKIT_API_SECRET | LiveKit API secret — use openssl rand -hex 16 | def456... |
POSTGRES_PASSWORD | Database password. Set it — Compose otherwise defaults to the literal string dissent. openssl rand -hex 24 | 7b2c... |
The two keys you cannot add later
These encrypt data at rest. They are optional in the sense that the node starts without them, and load-bearing in the sense that everything written while they are unset stays that way.
| Variable | Description |
|---|---|
MESSAGE_ENC_KEY | 64 hex chars (openssl rand -hex 32). Encrypts channel message content in Postgres (AES-256-GCM). Unset means every message is stored in plaintext — you get one startup warning and nothing else |
PLUGIN_CONFIG_KEY | 64 hex chars. Encrypts plugin configuration and stored storage credentials. If unset the node derives a key from JWT_SECRET — .env.example calls this legacy mode and does not recommend it |
These are not rotatable, and they are not in your database backup
Adding MESSAGE_ENC_KEY later encrypts only messages written after it; the earlier ones stay plaintext until you run cmd/encrypt-message-history. Changing either key without a re-encryption pass makes the data it protected permanently unreadable — the ciphertext is fine, the key that opens it is gone.
pg_dump does not contain them. Back up .env alongside the database, or your restore produces rows nobody can decrypt. See Backups & Restore.
Running bash setup.sh instead of editing by hand generates all three of these for you.
2. Start the stack, and claim it before exposing it
Do not start Caddy with the rest of the stack.
Until an administrator exists, the first account to register on your node becomes one — and Caddy obtaining a certificate publishes your hostname to Certificate Transparency logs within seconds, which is routinely scanned. Bringing everything up at once makes your node discoverable and claimable at the same instant.
bash
docker compose -f docker-compose.prod.yml up -d postgres redis apibash
docker compose -f docker-compose.prod.yml run --rm claim-node --username <your-name>Your node refuses all registration until this runs. While it has no administrator it mints a one-time claim token, prints it in the startup logs, and requires it for the first account — so nobody can take your node while you are setting it up. claim-node reads that token from the database itself, so you never have to handle it.
claim-node prints a 24-word recovery phrase and closes public registration. Write the phrase down — it is the only way into the account and it is not stored anywhere. Import it into any Dissent client to sign in.
If you would rather no secret was generated on the server, create the account in a client first and pass only its public key:
bash
docker compose -f docker-compose.prod.yml run --rm claim-node \
--username <your-name> --identity-pubkey <base64>Only now start Caddy:
bash
docker compose -f docker-compose.prod.yml up -dTo reopen registration later, use Node Admin → Settings in the app.
TIP
bash setup.sh does all of the above in the right order, including generating web-push keys. These steps are for installing by hand.
3. Verify
bash
# Check all services are healthy
docker compose -f docker-compose.prod.yml ps
# Check the API
curl https://your-domain.com/health
# Expected: {"ok":true,"data":{"status":"healthy"}}Environment Variable Reference
| Variable | Default | Description |
|---|---|---|
DATABASE_URL | (postgres service) | PostgreSQL connection string |
POSTGRES_PASSWORD | dissent | Database password. The default is a literal — set it |
REDIS_URL | (redis service) | Redis connection string |
JWT_SECRET | — | Required, ≥32 chars. Also the fallback source for PLUGIN_CONFIG_KEY |
PLUGIN_CONFIG_KEY | derived from JWT_SECRET | At-rest key for plugin configs and stored storage credentials. Set it |
MESSAGE_ENC_KEY | — | At-rest key for channel messages. Unset = plaintext |
NODE_ID | — | Required. Unique node identifier |
NODE_URL | — | Required. Public HTTPS URL |
CADDY_DOMAIN | — | Required. Domain for TLS certificate |
LIVEKIT_URL | ws://livekit:7880 | Server-to-server: this node → the SFU, on the internal network |
LIVEKIT_PUBLIC_URL | derived from NODE_URL | What clients are told to connect to. Blank derives wss://<NODE_URL host>/livekit, which matches the shipped Caddyfile. Set it only if your SFU lives elsewhere |
LIVEKIT_API_KEY | — | Required. LiveKit API key |
LIVEKIT_API_SECRET | — | Required. LiveKit API secret |
ALLOWED_ORIGINS | NODE_URL | CORS allowed origins, comma-separated. With neither this nor NODE_URL set, all cross-origin requests are denied — there is no * fallback |
FEDERATION_ENABLED | false | Enable federation with other nodes |
TRUSTED_PROXIES | — | CIDRs of reverse proxies whose CF-Connecting-IP may be trusted for rate limiting. Leave unset unless you run one |
ALLOWED_REGISTRIES | NODE_URL | Registry base URLs allowed to receive plugin install-count callbacks |
VAPID_PUBLIC_KEY | — | Web push public key (generated on first run) |
VAPID_PRIVATE_KEY | — | Web push private key (generated on first run) |
FFMPEG_PATH | ffmpeg from PATH | Used to derive clip posters and previews. Unset and unresolvable means clips still work, just slower to start |
LOG_FORMAT | json | Log format: json (prod) or console (dev) |
If neither this SFU nor a public one is configured
LIVEKIT_PUBLIC_URL resolves in order: the explicit value → wss://<NODE_URL host>/livekit → empty. On empty, clients are told voice is not configured and refuse to join a voice channel, rather than silently connecting to somebody else's SFU. If voice does nothing on a fresh node, check NODE_URL first.
Resource Tuning
| VPS Size | Memory limits | Notes |
|---|---|---|
| 2 vCPU / 2 GB | api: 512m, postgres: 512m, redis: 128m, livekit: 256m | Default — handles community-scale voice/video |
| 2 vCPU / 4 GB | api: 1g, postgres: 1g, redis: 256m, livekit: 512m | Comfortable headroom for larger servers |
To change limits, edit the deploy.resources.limits.memory fields in docker-compose.prod.yml.
Using an existing reverse proxy
If you already have nginx or Caddy running on the host, remove the caddy service from docker-compose.prod.yml and expose the api on a host port:
yaml
api:
ports:
- "127.0.0.1:8080:8080"Then proxy these routes to 127.0.0.1:8080 from your existing reverse proxy:
| Route | Goes to | Notes |
|---|---|---|
/api/* | api:8080 | |
/ws* | api:8080 | WebSocket — your proxy must pass upgrade headers |
/registry/* | api:8080 | |
/storage/* | api:8080 | |
/health | api:8080 | |
/livekit* | livekit:7880 | Strip the prefix. LiveKit serves /rtc at its root and knows nothing about /livekit — in Caddy this is handle_path, in nginx a trailing slash on proxy_pass |
Do not skip /livekit
It is the one route that is easy to miss and the only one whose absence breaks nothing visible until somebody tries to join a voice channel. LIVEKIT_PUBLIC_URL defaults to wss://<your domain>/livekit, so if that path is not proxied, the client connects to your web server and waits.
The shipped Caddyfile in the repository root is the reference implementation of exactly this mapping.
Updating
From your checkout of the repository:
bash
git pull
docker compose -f docker-compose.prod.yml -f docker-compose.build.yml up -d --buildMigrations run automatically on startup. Run the full up -d — not just the api service — so the bundled web client is refreshed along with the node.
Installed from the published image instead (the Quick Start)? Use bash update.sh — see Updating.
Serving the client yourself
By default, users connect via app.dissent.chat and pick your node on the login screen. To serve the client from your own domain instead:
Build the client with your node baked in:
bashcd dissent-client VITE_CORE_URL=https://node.example.com npm run buildCopy the output to a volume mapped to Caddy's
/srvdirectoryThe Caddyfile serves it automatically as a fallback for non-API routes
VITE_CORE_URL decides which node a fresh visitor talks to. The client resolves its node in this order:
- a node the user explicitly chose on the login screen (stored in their browser)
VITE_CORE_URL— the node this build was made for- the first bootstrap node shipped with the client
So a self-hosted client build reaches your node without the user configuring anything, and a user who deliberately picks a different node still gets it.
If you lose the claim token
The token is printed once per fresh node, in the API's startup logs:
bash
docker compose -f docker-compose.prod.yml logs api | grep claim_tokenIf it is gone, clear it and restart — the node mints a new one because it still has no administrator:
bash
docker compose -f docker-compose.prod.yml exec postgres \
psql -U dissent -d dissent -c "UPDATE node_settings SET claim_token = NULL WHERE id = 1;"
docker compose -f docker-compose.prod.yml restart apiAn unclaimed node is inert rather than broken: nobody can register on it, including you, until it is claimed. Once the first account exists the token is deleted and never returns.
WARNING
The token appears in your node's startup logs. If you ship those logs somewhere during setup, it travels with them. It is single-use and dies the moment the node is claimed.