Skip to content

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 .env

Compose 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.0

Edit .env with your values. Required fields:

VariableDescriptionExample
JWT_SECRETNode secret. Must be at least 32 characters and not the placeholder — the node refuses to start otherwise. openssl rand -hex 32a3f9...
NODE_IDUnique identifier for your node — use your domainnode.example.com
NODE_URLPublic HTTPS URL of your nodehttps://node.example.com
CADDY_DOMAINDomain Caddy obtains a TLS certificate fornode.example.com
LIVEKIT_API_KEYLiveKit API key — use openssl rand -hex 8abc123
LIVEKIT_API_SECRETLiveKit API secret — use openssl rand -hex 16def456...
POSTGRES_PASSWORDDatabase password. Set it — Compose otherwise defaults to the literal string dissent. openssl rand -hex 247b2c...

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.

VariableDescription
MESSAGE_ENC_KEY64 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_KEY64 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 api
bash
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 -d

To 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 ​

VariableDefaultDescription
DATABASE_URL(postgres service)PostgreSQL connection string
POSTGRES_PASSWORDdissentDatabase 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_KEYderived from JWT_SECRETAt-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_URLws://livekit:7880Server-to-server: this node → the SFU, on the internal network
LIVEKIT_PUBLIC_URLderived from NODE_URLWhat 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_ORIGINSNODE_URLCORS allowed origins, comma-separated. With neither this nor NODE_URL set, all cross-origin requests are denied — there is no * fallback
FEDERATION_ENABLEDfalseEnable 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_REGISTRIESNODE_URLRegistry 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_PATHffmpeg from PATHUsed to derive clip posters and previews. Unset and unresolvable means clips still work, just slower to start
LOG_FORMATjsonLog 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 SizeMemory limitsNotes
2 vCPU / 2 GBapi: 512m, postgres: 512m, redis: 128m, livekit: 256mDefault — handles community-scale voice/video
2 vCPU / 4 GBapi: 1g, postgres: 1g, redis: 256m, livekit: 512mComfortable 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:

RouteGoes toNotes
/api/*api:8080
/ws*api:8080WebSocket — your proxy must pass upgrade headers
/registry/*api:8080
/storage/*api:8080
/healthapi:8080
/livekit*livekit:7880Strip 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 --build

Migrations 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:

  1. Build the client with your node baked in:

    bash
    cd dissent-client
    VITE_CORE_URL=https://node.example.com npm run build
  2. Copy the output to a volume mapped to Caddy's /srv directory

  3. The 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:

  1. a node the user explicitly chose on the login screen (stored in their browser)
  2. VITE_CORE_URL — the node this build was made for
  3. 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_token

If 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 api

An 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.