Important
If you just want the TLDR take a look at stoat-compose.yml
If you're here looking for quick answers and don't care about reading, take a look at stoat-configs.
If you want an ansible example check out ansible-example-role.
Self hosting stoat with voice and video support
- Issues and PRs to keep track of voice and video progress
- Quick Reference: Credentials to Generate
- Architecture Overview
- Docker Networking
- Voice/Video - The Non-Obvious Stuff
- LiveKit
- MinIO S3 Storage - Virtual Host Addressing
- Service Dependencies
- Common Gotchas
- Internal Service Ports
- The Caddy Configuration
- Using Your Own Reverse Proxy
Stoat is currently in a transitionary period, therefore the documentation for self hosting with voice and video support is non-existent, and community patches to the front end have been made to enable the features.
There is also no official docker image for the web front end yet.
We'll be using the image graciously provided by baptisterajaut which uses the fork by LordGuenni
- #176 - Seems to be the main discussion thread right now.
- #313 - Progress tracker for voice/video.
- PR for dockerizing web front end
- PR to the official self hosted guide for setting up livekit
Before deploying, generate these credentials. All values marked CHANGE_ME_* in config files must be replaced.
| Credential | Used In | How to Generate |
|---|---|---|
| LiveKit API Key & Secret | Revolt.toml, livekit.yaml, compose | docker run --rm livekit/generate --local |
| MinIO User & Password | Revolt.toml (access_key_id/secret_access_key), compose |
openssl rand -hex 24 |
| RabbitMQ Password | Revolt.toml, compose | openssl rand -hex 24 |
| File Encryption Key | Revolt.toml (files.encryption_key) |
openssl rand -base64 32 |
| VAPID Keys | Revolt.toml (pushd.vapid) |
Not sure tbh. If you know please make a PR |
docker run --rm livekit/generate --localStoat runs as 14 interconnected services. Understanding how they communicate saves debugging time.
Infrastructure Layer - Standard backing services:
- MongoDB (
stoat-database) - Primary data store - Redis/KeyDB (
stoat-redis) - Caching, sessions, and pub/sub for LiveKit - RabbitMQ (
stoat-rabbit) - Async message queue for notifications and events - MinIO (
stoat-minio) - S3-compatible storage for file uploads - LiveKit (
stoat-livekit-server) - WebRTC server for voice/video
Application Layer - The actual Stoat services:
- API (
stoat-api) - REST API, the main backend - Events (
stoat-events) - WebSocket server for real-time updates - Autumn (
stoat-autumn) - File upload/download service - January (
stoat-january) - URL metadata extraction and image proxying - Gifbox (
stoat-gifbox) - Tenor GIF proxy - Crond (
stoat-crond) - Scheduled tasks - Pushd (
stoat-pushd) - Push notification delivery - Voice Ingress (
stoat-voice-ingress) - Voice call handling
Frontend Layer:
- Web (
stoat-web) - The web client - Caddy (
stoat-caddy) - Reverse proxy that routes everything
This is important to understand:
Revolt.toml is read by all the Stoat backend services (API, Events, Autumn, January, Gifbox, Crond, Pushd, Voice-Ingress). It contains:
- Database connection strings
- Service URLs (how services find each other)
- Feature flags and limits
- S3/MinIO credentials
- LiveKit node configuration
- Push notification settings
Environment variables are used by:
- Infrastructure services (MongoDB, Redis, RabbitMQ, MinIO, LiveKit)
- The web client (it doesn't read Revolt.toml at all)
The web client needs its own set of environment variables because it runs in the browser - it can't read server-side config files:
REVOLT_PUBLIC_URL - Where to find the API
VITE_WS_URL - WebSocket endpoint
VITE_MEDIA_URL - File server (Autumn)
VITE_PROXY_URL - Metadata proxy (January)
All Stoat containers must be on the same Docker network. This is how services find each other.
Docker provides internal DNS resolution - when containers are on the same network, they can reach each other by container name. This is why the configs use hostnames like stoat-database, stoat-redis, stoat-api, etc. Docker resolves these names to the container's internal IP.
For example, in Revolt.toml:
[database]
mongodb = "mongodb://stoat-database" # Resolves via Docker DNS
redis = "redis://stoat-redis/"If services can't find each other, the first thing to check is network membership:
docker network inspect stoat_networkAll 14 containers should be listed. If something's missing, it won't be able to resolve other container names.
Getting voice and video working required the most debugging. Here's what you need to know:
Stoat moved to using LiveKit to handle voice and video streams. We just use the official livekit image in our setup.
The standard Stoat/Revolt web client does not include voice/video support. The LiveKit integration exists in the backend, but the official web client doesn't have the UI for it (yet).
baptisterajaut/stoatchat-web:dev is a community-patched image that adds the voice/video UI. Without this, you'll have a working LiveKit server that nothing can connect to.
The API will panic with missing field 'lat' if you don't include geographic coordinates in your LiveKit node config:
[api.livekit.nodes.worldwide]
url = "http://stoat-livekit-server:7880"
lat = 0.0 # Required!
lon = 0.0 # Required!
key = "..."
secret = "..."This one's subtle. The node name in [hosts.livekit] must match the section name in [api.livekit.nodes.*]:
[hosts.livekit]
worldwide = "wss://your-domain/livekit" # "worldwide" is the node name
[api.livekit.nodes.worldwide] # Must match "worldwide"
url = "http://stoat-livekit-server:7880"
# ...If these don't match, voice/video calls will fail silently or with confusing errors.
LiveKit needs three types of network access:
- 7880/tcp (internal only) - HTTP API, used by Stoat services
- 7881/tcp (external) - WebRTC signaling over TCP
- 50000-50100/udp (external) - Actual media streams
The UDP range is where voice/video data flows. If these ports are blocked, calls will either fail or fall back to TCP (higher latency). Make sure your firewall allows this UDP range inbound.
MinIO needs a bunch of network aliases:
aliases:
- minio
- revolt-uploads.minio
- attachments.minio
- avatars.minio
- backgrounds.minio
- icons.minio
- banners.minio
- emojis.minioWhy? MinIO supports virtual-host style bucket addressing where bucketname.minio routes to the bucketname bucket. Stoat's file server (Autumn) uses this pattern to access different buckets for different file types.
Without these aliases, file uploads will fail with connection errors because avatars.minio won't resolve to anything.
The actual bucket created is just revolt-uploads - the aliases are for routing, not for creating separate buckets.
Understanding startup order helps with debugging:
- MongoDB, Redis, RabbitMQ, MinIO - No dependencies, start first
- MinIO bucket creation - Runs once after MinIO is healthy
- LiveKit - Needs Redis for coordination
- All Stoat services - Need MongoDB, RabbitMQ, and Revolt.toml mounted
- Caddy - Needs all backend services ready to proxy to
Health checks matter here. MongoDB and RabbitMQ have health checks that other services wait on. If your deployment is flaky on startup, check that health checks are passing.
Many times during debugging I chased red herrings, when all I had to do was empty cache and hard reload on my browser.
Add lat = 0.0 and lon = 0.0 to your [api.livekit.nodes.*] config. See LiveKit section above.
You're using the wrong web client image. Use baptisterajaut/stoatchat-web:dev instead of the official image.
- Check MinIO is running and the
revolt-uploadsbucket exists - Verify MinIO has all the network aliases configured
- Check Revolt.toml S3 credentials match MinIO's MINIO_ROOT_USER/PASSWORD
The /ws path needs proper WebSocket upgrade handling. Caddy handles this by default, but if you're using a different reverse proxy, make sure it's configured for WebSocket.
All services must be on the same Docker network. Check docker network ls and ensure everything's connected to your stoat network.
Usually a Revolt.toml syntax error or missing required field. Check container logs - TOML parse errors are usually descriptive.
For reference, these are the ports services listen on inside the Docker network:
| Service | Port |
|---|---|
| API | 14702 |
| Events (WebSocket) | 14703 |
| Autumn (files) | 14704 |
| January (proxy) | 14705 |
| Gifbox | 14706 |
| Web | 5000 |
| LiveKit | 7880 |
| MongoDB | 27017 |
| Redis | 6379 |
| RabbitMQ | 5672 |
| MinIO | 9000 |
You shouldn't need to expose these externally - Caddy proxies everything through a single port.
Caddy acts as the single entry point, routing requests to the correct backend service based on URL path. Here's what each route does:
You can refer to the Example Caddyfile
/api/* → stoat-api:14702 (REST API)
/livekit/* → stoat-livekit:7880 (WebRTC signaling for voice/video)
/ws → stoat-events:14703 (WebSocket for real-time updates)
/autumn/* → stoat-autumn:14704 (File uploads and downloads)
/january/* → stoat-january:14705 (URL metadata and image proxy)
/gifbox/* → stoat-gifbox:14706 (Tenor GIF proxy)
/* → stoat-web:5000 (Web frontend - default/fallback)
The uri strip_prefix directive is important - it removes the path prefix before forwarding. So a request to /api/users becomes /users when it hits the API server. The backends expect paths without the prefix.
If you already have nginx, Traefik, or another reverse proxy, you can skip Caddy entirely. You just need to replicate the routing.
OR
You can just bind the caddy container on a different port on your host and forward request from your main proxy to it.
Two paths require WebSocket upgrades:
/ws- Real-time events/livekit/*- Voice/video signaling
Make sure your proxy can handle this.
Whatever proxy you use, configure these routes:
| Path | Backend | Notes |
|---|---|---|
/api/* |
stoat-api:14702 | Strip /api prefix |
/ws |
stoat-events:14703 | WebSocket upgrade required |
/livekit/* |
stoat-livekit-server:7880 | WebSocket upgrade required, strip /livekit prefix |
/autumn/* |
stoat-autumn:14704 | Strip /autumn prefix |
/january/* |
stoat-january:14705 | Strip /january prefix |
/gifbox/* |
stoat-gifbox:14706 | Strip /gifbox prefix |
/* |
stoat-web:5000 | Default fallback |