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
| Project | Revolt / Stoat (rebranded October 2025) |
| License | AGPL-3.0 |
| Repo | github.com/stoatchat/self-hosted |
| Recommended Plan | Cloud VPS 2 vCPU / 4 GB RAM / 60 GB SSD (small community) |
| OS | Ubuntu 22.04 or 24.04 LTS |
| Estimated Setup Time | 45–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.
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:
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.
Initial Server Setup
SSH in and open the LiveKit voice ports up front — without these, voice channels fail silently.
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 enablesudo 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
rebootInstall Docker
The self-hosted stack uses Docker Engine + Compose v2. The standalone docker-compose binary is unsupported.
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 versionClone and Configure Stoat
cd /opt
git clone https://github.com/stoatchat/self-hosted stoat
cd stoat
chmod +x ./generate_config.sh
./generate_config.sh chat.example.comThe 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.
cp secrets.env /root/stoat-secrets.env.backup
chmod 600 /root/stoat-secrets.env.backupReview 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 = truefor 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:
[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.
First Start
docker compose upCaddy 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 connectionson 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
# Ctrl+C, then:
docker compose up -dVisit https://chat.example.com, register the first account — that becomes your admin once elevated.
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:
services:
caddy:
ports:
- "1234:80"
# - "443:443"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.
Make It Invite-Only
[api.registration]
invite_only = truedocker compose up -d
docker compose exec database mongosh
# inside the shell
use revolt
db.invites.insertOne({ _id: "your-invite-code-here" })
exitThe database is still named revolt despite the rebrand — leave it alone.
Updating
Always read the upstream README's notices section before updating. Recent breaking changes:
- Nov 2024: push notification config moved from
api.vapid/fcm/apntopushd.*;rabbit+pushdservices added - Sep 2024: Autumn refactor — older instances need a manual MongoDB migration
- Oct 2025: Compose project name changed from
revolttostoat; rundocker compose -p revolt downafter 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
cd /opt/stoat
git pull
docker compose pull
docker compose up -dBackups
Three things matter: MongoDB (every message + permission), MinIO (every uploaded file), and secrets.env + Revolt.toml (without these a restored DB is unusable).
#!/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.webdoesn'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.4in a compose override - Upload CORS error: Autumn URL wrong, or you crossed the Sep 2024 refactor without running the migration
