Skip to content

Production deployment

End-to-end walkthrough: fresh VPS → working chatalot at https://chat.yourdomain.com in about 15 minutes.

This page is the canonical "how do I stand this up for real" path. It assumes you have:

  • A Linux VPS (Debian 12+, Ubuntu 24.04+, or similar) with root or sudo access.
  • A domain name where you can point an A/AAAA record at the VPS.
  • Docker Engine and Compose v2 installed.
  • A reverse proxy that can terminate TLS (Caddy, Traefik, or nginx). A minimal Caddy snippet is shown below if you don't have one yet.

If you only want a local-only dev instance or you're evaluating on your laptop, use the interactive installer instead — this page is tuned for production.

TL;DR — one-command install

Clone the repo on the VPS, then run:

./scripts/install.sh --non-interactive \
    --domain chat.yourdomain.com \
    --admin-username alice \
    --registration invite_only \
    --enable-turn --turn-external-ip YOUR.VPS.PUBLIC.IP \
    --enable-updater \
    --use-prebuilt

That writes a production-shaped configuration file, generates every required secret, seeds the update sidecar, and runs docker compose up -d with the right profiles. Final output includes a machine-parseable summary so an AI agent can pipe it forward:

public_url=https://chat.yourdomain.com
admin_username=alice
registration_mode=invite_only
turn_enabled=1
updater_enabled=1
compose_profiles=turn updater

What's still on you after the installer finishes: DNS, reverse proxy + TLS, firewall, and (if TURN is enabled) opening UDP/TCP 3478 and 5349.

Prerequisites

VPS sizing

Scale RAM vCPU Disk Notes
Personal / small team 1 GB 1 20 GB No TURN; voice/video may NAT-traverse.
Team + voice 2 GB 2 30 GB TURN required if users are behind restrictive NAT.
Production workload 4 GB+ 2+ 60 GB+ Headroom for file uploads + pg_dump snapshots.

Packages

# Debian / Ubuntu
sudo apt update && sudo apt install -y docker.io docker-compose-v2 openssl curl git

# Fedora / RHEL
sudo dnf install -y docker docker-compose openssl curl git
sudo systemctl enable --now docker

# Arch
sudo pacman -S docker docker-compose openssl curl git
sudo systemctl enable --now docker

DNS

Point an A (and ideally AAAA) record at the VPS public IP before you start the installer. The PUBLIC_URL and OIDC redirect URIs are baked into the configuration file; changing the domain later is a one-file edit plus a docker compose up -d to pick it up.

Install

1. Clone the repo

git clone https://forgejo.seglamater.app/seglamater/chatalot.git /srv/chatalot
cd /srv/chatalot

(The path doesn't matter; /srv/chatalot is convention. The installer runs relative to the repo root.)

2. Run the installer

Minimal production invocation:

./scripts/install.sh --non-interactive \
    --domain chat.yourdomain.com \
    --admin-username alice \
    --registration invite_only \
    --use-prebuilt

Add features as needed:

  • Voice/video: --enable-turn --turn-external-ip <public IPv4>. The installer generates the TURN auth secret and writes the TURN URLs into the configuration file. You still need to open UDP/TCP 3478 and 5349 on your firewall.
  • Managed updates: --enable-updater. Runs the chatalot-updater sidecar so the admin panel can pull signed releases from the official registry. Covered in depth at docs/admin-guide/updater.md.
  • Authenticated image pulls (required when pulling from a private registry): --registry-user <name> --registry-token <token>. Writes secrets/registry_creds with the credentials. If your registry is public, omit these.
  • OIDC/SSO: --oidc-issuer <issuer-URL> --oidc-client-id <id> --oidc-client-secret <secret>. Add --oidc-disable-password-login to force OIDC-only logins.

Run ./scripts/install.sh --help for the full flag list.

3. Put a reverse proxy in front

The installer does NOT install or configure your reverse proxy — that's an operator choice and varies too much to script safely. Chatalot listens on 127.0.0.1:8080 by default; your proxy terminates TLS at :443 and forwards to it. WebSocket upgrades at /ws are required for real-time features.

A minimal Caddyfile:

chat.yourdomain.com {
    encode zstd gzip
    reverse_proxy 127.0.0.1:8080
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        -Server
    }
}

Caddy auto-provisions a Let's Encrypt cert on first request. If you prefer Traefik or nginx, any proxy that correctly handles WebSocket upgrades works; see cloudflare-tunnel.md for the tunnel-based alternative.

4. Verify

# Local health endpoint
curl -sf http://127.0.0.1:8080/api/health | jq

# Public health endpoint (once DNS + TLS are live)
curl -sf https://chat.yourdomain.com/api/health | jq

Expected response:

{"status":"ok","version":"0.24.6","uptime_secs":12,"db_healthy":true}

Then open https://chat.yourdomain.com in a browser and register the admin user matching --admin-username. The first user to register under that name gets admin; subsequent registrations under the same name are treated as an attempt to log in.

Firewall

Port Protocol Purpose Required?
80, 443 TCP Reverse proxy (HTTP/HTTPS) Always
3478 UDP + TCP TURN (plaintext) With --enable-turn
5349 UDP + TCP TURN over TLS/DTLS With --enable-turn

The chatalot backend port (8080) should NOT be exposed directly to the public internet — bind it to localhost and let the reverse proxy gate it.

Post-install: operator tasks

  1. Back up secrets/. The installer generates one-of-a-kind cryptographic material there (JWT private key, TOTP encryption key, TURN auth secret, updater HMAC token). Losing these means existing sessions, TOTP codes, and managed-update HMAC will all break. Offline copy, encrypted backup, password manager — your pick, but somewhere not on the same VPS.
  2. Replace the seeded cosign_pub if you're using managed updates. The installer seeds secrets/cosign_pub from the example file so the updater sidecar can start. For production you want the real public key corresponding to the signing channel you trust. See ../admin-guide/updater.md for the canonical Seglamater pubkey + fingerprint.
  3. Rotate registry_creds on a schedule if you're pulling authenticated. The installer writes the value you passed in verbatim; regenerating on a cadence (e.g., at key-rotation time for your registry) is just a matter of rewriting the file and docker compose restart chatalot-updater.
  4. Turn on backups. Managed updates take their own pre-apply snapshot, but an independent backup of postgres + uploaded files is non-negotiable. See backup-and-restore.md.

Managed updates in one minute

With --enable-updater, the admin-facing Updates panel polls the configured release channel, shows available versions, and (per the CHAT-13 vendor/client split) requires an explicit admin click to apply. An apply:

  1. Pulls the new image (cosign-verified against the public key).
  2. Snapshots the database.
  3. Runs the release's pre_flight.sh in a throwaway container.
  4. Stops the old container, runs migrate.sh, starts the new one.
  5. Waits for the new container's health check; on failure, restores the snapshot and recreates the old container.

See ../admin-guide/updater.md for the full flow and the HMAC/token model.

Troubleshooting

./scripts/install.sh: permission denied

chmod +x ./scripts/install.sh

Health endpoint returns 502 through the proxy but 200 on localhost

Reverse proxy isn't forwarding WebSocket upgrades, or port 8080 is bound to 0.0.0.0 and firewalled. Check the proxy logs first; the WS path is /ws.

docker compose up -d fails with "secret file not found"

An expected secret file is missing. The installer creates them all; if you ran with a custom --data-dir or edited the compose file by hand, make sure every referenced secret exists:

ls -la secrets/
# expected for a full install:
#   jwt_private.pem  jwt_public.pem  db_password  totp_encryption_key
#   updater_token  cosign_pub  registry_creds   # when --enable-updater

TURN works on LAN but fails from outside

Firewall / NAT. TURN needs UDP 3478 + 5349 (and TCP for TURN-over-TCP fallback) forwarded to the VPS. TURN_EXTERNAL_IP must match the public address clients resolve to; using a private 10.x / 192.168.x address here is the single most common misconfiguration.

Admin account stuck

ADMIN_USERNAME is honored at first-launch bootstrap when there are zero admins. If an admin already exists, the value is ignored silently. To promote an existing user, use the admin panel (logged in as another admin), or hit the database directly:

docker compose exec postgres psql -U chatalot -c "UPDATE users SET is_admin=true WHERE username='alice';"

Next steps