Chat Platform
    LiveKit Voice
    AGPL-3.0

    Deploy Revolt (Stoat) Chat on a VPS

    A self-hosted, user-first Discord alternative — servers, channels, roles, file uploads, and LiveKit voice — running on Docker Compose behind Caddy with automatic Let's Encrypt.

    At a Glance

    ProjectRevolt / Stoat (rebranded October 2025)
    LicenseAGPL-3.0
    Repogithub.com/stoatchat/self-hosted
    Recommended PlanCloud VPS 2 vCPU / 4 GB RAM / 60 GB SSD (small community)
    OSUbuntu 22.04 or 24.04 LTS
    Estimated Setup Time45–75 minutes

    Heads up on the name

    As of October 2025, Revolt rebranded to Stoat. The project, license, maintainers, and architecture are unchanged. The org moved from revoltchat to stoatchat, but most internal config (the Revolt.toml filename, the revolt MongoDB database, REVOLT_* env vars) still uses the old name. Everywhere this guide says "Stoat," your existing knowledge of Revolt applies directly.

    1

    Sizing and DNS

    The stack runs roughly a dozen containers; MongoDB alone wants ~1 GB headroom under load. Recommended sizing:

    • <50 users, light voice: 2 vCPU / 4 GB / 60 GB SSD
    • 50–250 users, regular voice: 4 vCPU / 8 GB / 100 GB+ SSD
    • Larger or busy voice: 6+ vCPU / 16 GB and dedicated storage

    Point a single hostname at the VPS:

    DNS records
    A     chat.example.com  ->  YOUR_VPS_IPV4
    AAAA  chat.example.com  ->  YOUR_VPS_IPV6 (optional)

    If you front Stoat with Cloudflare, set the proxy to DNS only (gray cloud) for the initial deployment. Caddy needs direct ACME, and the Cloudflare proxy will break WebSocket and LiveKit RTC.

    2

    Initial Server Setup

    SSH in and open the LiveKit voice ports up front — without these, voice channels fail silently.

    Update + firewall
    apt-get update && apt-get upgrade -y
    
    ufw allow ssh
    ufw allow http
    ufw allow https
    ufw allow 7881/tcp
    ufw allow 50000:50100/udp
    ufw default deny
    ufw enable
    Lock down SSH (key-only)
    sudo sed -E -i 's|^#?(PasswordAuthentication)\s.*|\1 no|' /etc/ssh/sshd_config
    if ! grep '^PasswordAuthentication\s' /etc/ssh/sshd_config; then
      echo 'PasswordAuthentication no' | sudo tee -a /etc/ssh/sshd_config
    fi
    reboot
    3

    Install Docker

    The self-hosted stack uses Docker Engine + Compose v2. The standalone docker-compose binary is unsupported.

    Docker Engine + Compose plugin
    apt-get update
    apt-get install -y ca-certificates curl git micro
    
    install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
    chmod a+r /etc/apt/keyrings/docker.asc
    
    echo \
      "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
      $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
      | tee /etc/apt/sources.list.d/docker.list > /dev/null
    
    apt-get update
    apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    
    docker --version && docker compose version
    4

    Clone and Configure Stoat

    Clone + generate config
    cd /opt
    git clone https://github.com/stoatchat/self-hosted stoat
    cd stoat
    
    chmod +x ./generate_config.sh
    ./generate_config.sh chat.example.com

    The script writes Revolt.toml, .env.web, secrets.env, livekit.yml, and the Caddy config. Replace the hostname with your real one — it gets baked into every URL the services advertise.

    Critical: secrets.env contains VAPID push keys, MinIO root creds, and the JWT signing secret. Back it up before doing anything else. Lose it and you lose access to every uploaded file, and existing sessions break. Re-running generate_config.sh will overwrite it.

    Back up secrets immediately
    cp secrets.env /root/stoat-secrets.env.backup
    chmod 600 /root/stoat-secrets.env.backup
    5

    Review the Generated Config

    Open Revolt.toml and set:

    • [api.smtp] — SMTP creds for email verification and password resets
    • [api.security.captcha] — hCaptcha keys to limit spam signups
    • [api.registration] — set invite_only = true for closed instances
    • [files.s3] — swap to external S3 (B2/Wasabi/AWS) if you'd rather not use bundled MinIO

    Two values are non-negotiable and set automatically — don't edit manually:

    Internal Docker network URLs (do not change)
    [api.livekit.nodes.worldwide]
    url = "http://livekit:7880"
    
    # livekit.yml
    webhook:
      urls:
        - "http://voice-ingress:8500/worldwide"

    A bug in generate_config.sh around February 20, 2026 produced wrong values for these. If you cloned in that window, double-check both fields manually.

    6

    First Start

    Foreground first to make problems visible
    docker compose up

    Caddy provisions Let's Encrypt on first start (30–90 seconds). Watch for certificate obtained. ACME failures usually mean: DNS not propagated, port 80 firewalled, or Cloudflare proxy enabled.

    Healthy startup lines:

    • MongoDB: Waiting for connections on 27017
    • KeyDB/Redis: Ready to accept connections
    • Delta (api): Listening on 0.0.0.0:8000
    • Bonfire (events): WebSocket server listening
    • LiveKit: starting LiveKit server
    Detach when stable
    # Ctrl+C, then:
    docker compose up -d

    Visit https://chat.example.com, register the first account — that becomes your admin once elevated.

    7

    Reverse Proxy Alternatives

    The default Caddy on 80/443 handles ACME and WebSocket upgrade headers correctly. If you already run nginx on the host, repoint Caddy to a non-standard port and proxy through nginx:

    compose.yml override
    services:
      caddy:
        ports:
          - "1234:80"
          # - "443:443"
    nginx forwards / and the WebSocket paths
    server {
        listen 443 ssl http2;
        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;
    
        location / {
            proxy_pass http://localhost:1234;
            proxy_set_header Host $server_name;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    
        location /ws {
            proxy_pass http://localhost:1234;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $server_name;
        }
    
        location /livekit {
            proxy_pass http://localhost:1234;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $server_name;
        }
    }

    LiveKit RTC traffic on 7881/tcp + 50000–50100/udp is not proxied — it hits the VPS directly through Docker port publishing. Don't try to route it through nginx.

    8

    Make It Invite-Only

    Revolt.toml
    [api.registration]
    invite_only = true
    Restart and add an invite
    docker compose up -d
    
    docker compose exec database mongosh
    
    # inside the shell
    use revolt
    db.invites.insertOne({ _id: "your-invite-code-here" })
    exit

    The database is still named revolt despite the rebrand — leave it alone.

    9

    Updating

    Always read the upstream README's notices section before updating. Recent breaking changes:

    • Nov 2024: push notification config moved from api.vapid/fcm/apn to pushd.*; rabbit + pushd services added
    • Sep 2024: Autumn refactor — older instances need a manual MongoDB migration
    • Oct 2025: Compose project name changed from revolt to stoat; run docker compose -p revolt down after pulling
    • Feb 2026: LiveKit + new web app added — run the migration once only:
      One-time February 2026 migration
      git pull
      chmod +x migrations/20260218-voice-config.sh
      ./migrations/20260218-voice-config.sh chat.example.com
    Routine update
    cd /opt/stoat
    git pull
    docker compose pull
    docker compose up -d
    10

    Backups

    Three things matter: MongoDB (every message + permission), MinIO (every uploaded file), and secrets.env + Revolt.toml (without these a restored DB is unusable).

    /opt/stoat-backup.sh
    #!/bin/bash
    set -e
    
    BACKUP_DIR=/var/backups/stoat
    TIMESTAMP=$(date +%Y%m%d-%H%M%S)
    mkdir -p "$BACKUP_DIR/$TIMESTAMP"
    
    cd /opt/stoat
    
    docker compose exec -T database mongodump --archive --gzip \
      > "$BACKUP_DIR/$TIMESTAMP/mongo.archive.gz"
    
    cp secrets.env Revolt.toml .env.web livekit.yml "$BACKUP_DIR/$TIMESTAMP/"
    
    docker run --rm \
      --volumes-from "$(docker compose ps -q minio)" \
      -v "$BACKUP_DIR/$TIMESTAMP":/backup \
      alpine tar czf /backup/minio.tar.gz /data
    
    find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +7 -exec rm -rf {} \;

    Schedule from cron and ship the archives offsite with restic to a second VPS or B2/Wasabi.

    Common Issues

    • Caddy fails to issue cert: DNS not propagated, port 80 firewalled, or Cloudflare proxy enabled
    • Voice connects but no audio: RTC ports 7881/tcp + 50000–50100/udp blocked — check ufw status verbose
    • "Network error" on login: hostname in Revolt.toml/.env.web doesn't match the URL — regenerate (and re-back-up secrets)
    • MongoDB won't start, complains about CPU: older host lacks AVX — pin to mongo:4.4 in a compose override
    • Upload CORS error: Autumn URL wrong, or you crossed the Sep 2024 refactor without running the migration