Docker Compose
This guide walks through deploying Chatto with Docker Compose, including NATS for messaging, LiveKit for voice and video calls, and Caddy as a reverse proxy with automatic TLS.
Architecture
Section titled “Architecture”Caddy terminates TLS and proxies traffic to both Chatto and LiveKit’s WebSocket signaling endpoint. LiveKit’s WebRTC media traffic (UDP) is exposed directly — it can’t be proxied.
Prerequisites
Section titled “Prerequisites”- Docker and Docker Compose v2
- A domain pointing to your server (e.g.,
chat.example.com) - A
livekit.*subdomain pointing to the same server (e.g.,livekit.chat.example.com) - Firewall allowing inbound TCP 80, 443 and UDP 50000-50200
By the end of this section, your project directory will look like this:
- compose.yml
- Caddyfile
- livekit.yaml
- .env
-
Create the project directory
Terminal window mkdir chatto && cd chatto -
Create
compose.ymlservices:nats:image: nats:latestcommand: ["--jetstream", "--store_dir=/data", "--auth=${NATS_TOKEN}"]volumes:- nats_data:/datahealthcheck:test: ["CMD", "nats-server", "--help"]interval: 5stimeout: 3sretries: 3livekit:image: livekit/livekit-server:latestcommand:- --config- /etc/livekit.yamlports:- "50000-50200:50000-50200/udp"expose:- "7880"volumes:- ./livekit.yaml:/etc/livekit.yaml:rohealthcheck:test: ["CMD", "wget", "-q", "--spider", "http://localhost:7880"]interval: 5stimeout: 3sretries: 3chatto:image: ghcr.io/hmans/chatto:latestenv_file: .envdepends_on:nats:condition: service_healthylivekit:condition: service_healthyexpose:- "4000"caddy:image: caddy:latestports:- "80:80"- "443:443"environment:PUBLIC_URL: "${PUBLIC_URL:-chat.example.com}"volumes:- ./Caddyfile:/etc/caddy/Caddyfile:ro- caddy_data:/data- caddy_config:/configdepends_on:- chatto- livekitvolumes:nats_data:caddy_data:caddy_config: -
Create
CaddyfileCaddy proxies Chatto on the main domain and LiveKit’s WebSocket signaling on the
livekit.*subdomain:{$PUBLIC_URL:chat.example.com} {reverse_proxy chatto:4000 {lb_policy round_robinlb_try_duration 5sfail_duration 30sheader_up X-Real-IP {remote_host}header_up X-Forwarded-For {remote_host}header_up X-Forwarded-Proto {scheme}}encode gzip zstd}livekit.{$PUBLIC_URL:chat.example.com} {reverse_proxy livekit:7880} -
Create
livekit.yamlport: 7880rtc:port_range_start: 50000port_range_end: 50200use_external_ip: truekeys:# Must match CHATTO_LIVEKIT_API_KEY / CHATTO_LIVEKIT_API_SECRET in .envyour-api-key: your-api-secretwebhook:urls:- https://chat.example.com/webhooks/livekitapi_key: your-api-keylogging:level: info -
Create
.envTerminal window # Generate secrets with: openssl rand -hex 32PUBLIC_URL=chat.example.com# NATSNATS_TOKEN=<generate-me># ChattoCHATTO_NATS_EMBEDDED_ENABLED=falseCHATTO_NATS_CLIENT_URL=nats://nats:4222CHATTO_NATS_CLIENT_AUTH_METHOD=tokenCHATTO_NATS_CLIENT_TOKEN=<same-as-NATS_TOKEN>CHATTO_WEBSERVER_URL=https://chat.example.comCHATTO_WEBSERVER_PORT=4000CHATTO_WEBSERVER_COOKIE_SIGNING_SECRET=<generate-me>CHATTO_CORE_ASSETS_SIGNING_SECRET=<generate-me>CHATTO_LOG_LEVEL=info# LiveKitCHATTO_LIVEKIT_ENABLED=trueCHATTO_LIVEKIT_URL=wss://livekit.chat.example.comCHATTO_LIVEKIT_API_KEY=your-api-keyCHATTO_LIVEKIT_API_SECRET=your-api-secretReplace all
<generate-me>values with output fromopenssl rand -hex 32. Make sureNATS_TOKENandCHATTO_NATS_CLIENT_TOKENmatch, and the LiveKit key/secret match what’s inlivekit.yaml. -
Start the stack
Terminal window docker compose up -dCaddy will automatically obtain TLS certificates for both domains. Visit
https://chat.example.comto create your first account.
Operations
Section titled “Operations”# View logsdocker compose logs -f
# Logs for a specific servicedocker compose logs -f chatto
# Update to latest imagesdocker compose pull && docker compose up -d
# Stop everythingdocker compose down
# Stop and delete all datadocker compose down -vPersistent Data
Section titled “Persistent Data”| Volume | Contents |
|---|---|
nats_data | NATS/JetStream streams, KV stores — all chat data lives here |
caddy_data | TLS certificates from Let’s Encrypt |
caddy_config | Caddy runtime configuration cache |
Back up nats_data regularly. By default, file attachments and media are also stored in NATS — for heavier usage, consider offloading them to S3-compatible storage. Chatto also supports running multiple replicas behind a load balancer for high availability.
Optional Features
Section titled “Optional Features”SMTP (Email)
Section titled “SMTP (Email)”Required for email verification and password reset. Add to .env:
CHATTO_SMTP_ENABLED=trueCHATTO_SMTP_HOST=smtp.example.comCHATTO_SMTP_PORT=587CHATTO_SMTP_USERNAME=your-usernameCHATTO_SMTP_PASSWORD=your-passwordCHATTO_SMTP_FROM=noreply@example.comPush Notifications
Section titled “Push Notifications”For notifications when the browser is closed. Generate VAPID keys with npx web-push generate-vapid-keys, then add to .env:
CHATTO_PUSH_ENABLED=trueCHATTO_PUSH_VAPID_PUBLIC_KEY=BNx...CHATTO_PUSH_VAPID_PRIVATE_KEY=...CHATTO_PUSH_VAPID_SUBJECT=mailto:admin@example.comVideo Processing
Section titled “Video Processing”The official Chatto Docker image includes ffmpeg. To enable video transcoding, add to .env:
CHATTO_VIDEO_ENABLED=trueDisabling Voice and Video Calls
Section titled “Disabling Voice and Video Calls”If you don’t need voice and video calls, simplify the stack by:
- Removing the
livekitservice and itsdepends_onreferences fromcompose.yml - Deleting the
livekit.yamlfile - Removing the
livekit.*block from theCaddyfile - Removing the
CHATTO_LIVEKIT_*variables from.env
You won’t need the livekit.* subdomain or the UDP port range.
TURN Server for Restrictive Networks
Section titled “TURN Server for Restrictive Networks”The default setup exposes LiveKit’s UDP port range directly. This works for most networks, but users behind strict corporate firewalls or symmetric NATs that block all UDP traffic won’t be able to join calls.
If your users are on restrictive networks, you have two options:
Option A: LiveKit’s Built-in TURN Server
Section titled “Option A: LiveKit’s Built-in TURN Server”LiveKit includes a TURN server that can relay media over TCP. To use it, the TURN server needs its own port that doesn’t conflict with Caddy’s port 443.
Add to livekit.yaml:
turn: enabled: true tls_port: 5349Expose the TURN port in compose.yml under the livekit service:
ports: - "50000-50200:50000-50200/udp" - "5349:5349/tcp" # TURN/TLS relayOption B: Dedicated TURN Server (coturn)
Section titled “Option B: Dedicated TURN Server (coturn)”For maximum compatibility, run coturn as a dedicated TURN server, optionally on a separate host or IP. Add to compose.yml:
coturn: image: coturn/coturn:latest network_mode: host volumes: - ./turnserver.conf:/etc/coturn/turnserver.conf:roCreate turnserver.conf:
listening-port=3478tls-listening-port=443realm=chat.example.com# Static credentials (must match LiveKit config)user=livekit:your-turn-passwordlt-cred-mech# TLS (use Caddy's certificates or provide your own)cert=/path/to/cert.pempkey=/path/to/key.pemThen configure LiveKit to use the external TURN server by adding to livekit.yaml:
rtc: turn_servers: - host: turn.example.com port: 443 protocol: tls username: livekit credential: your-turn-passwordTroubleshooting
Section titled “Troubleshooting”Chatto can’t connect to NATS — Ensure NATS_TOKEN and CHATTO_NATS_CLIENT_TOKEN match in .env.
Caddy not getting certificates — Verify DNS records point to your server and ports 80/443 are open. Check docker compose logs caddy for ACME errors.
Container startup order — depends_on with condition: service_healthy ensures NATS and LiveKit are ready before Chatto starts. If health checks fail, check the individual service logs.
Calls don’t connect — Verify the LiveKit API key/secret in .env matches livekit.yaml. Ensure CHATTO_LIVEKIT_URL uses the public wss://livekit.* subdomain (browsers connect to it directly). Check that UDP 50000-50200 is open in your firewall.
Calls fail for some users — Users behind firewalls that block UDP need a TURN relay. See TURN server for restrictive networks.