Skip to content

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.

DOCKER NETWORK BrowserSvelteKit SPA :443CaddyTLS · Load Balancer :4222NATSJetStream · KV Store :4000ChattoGraphQL · WebSocket :7880LiveKitWebRTC Media HTTPS WSS UDP :50000–50200

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.

  • 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
  1. Create the project directory

    Terminal window
    mkdir chatto && cd chatto
  2. Create compose.yml

    services:
    nats:
    image: nats:latest
    command: ["--jetstream", "--store_dir=/data", "--auth=${NATS_TOKEN}"]
    volumes:
    - nats_data:/data
    healthcheck:
    test: ["CMD", "nats-server", "--help"]
    interval: 5s
    timeout: 3s
    retries: 3
    livekit:
    image: livekit/livekit-server:latest
    command:
    - --config
    - /etc/livekit.yaml
    ports:
    - "50000-50200:50000-50200/udp"
    expose:
    - "7880"
    volumes:
    - ./livekit.yaml:/etc/livekit.yaml:ro
    healthcheck:
    test: ["CMD", "wget", "-q", "--spider", "http://localhost:7880"]
    interval: 5s
    timeout: 3s
    retries: 3
    chatto:
    image: ghcr.io/hmans/chatto:latest
    env_file: .env
    depends_on:
    nats:
    condition: service_healthy
    livekit:
    condition: service_healthy
    expose:
    - "4000"
    caddy:
    image: caddy:latest
    ports:
    - "80:80"
    - "443:443"
    environment:
    PUBLIC_URL: "${PUBLIC_URL:-chat.example.com}"
    volumes:
    - ./Caddyfile:/etc/caddy/Caddyfile:ro
    - caddy_data:/data
    - caddy_config:/config
    depends_on:
    - chatto
    - livekit
    volumes:
    nats_data:
    caddy_data:
    caddy_config:
  3. Create Caddyfile

    Caddy 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_robin
    lb_try_duration 5s
    fail_duration 30s
    header_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
    }
  4. Create livekit.yaml

    port: 7880
    rtc:
    port_range_start: 50000
    port_range_end: 50200
    use_external_ip: true
    keys:
    # Must match CHATTO_LIVEKIT_API_KEY / CHATTO_LIVEKIT_API_SECRET in .env
    your-api-key: your-api-secret
    webhook:
    urls:
    - https://chat.example.com/webhooks/livekit
    api_key: your-api-key
    logging:
    level: info
  5. Create .env

    Terminal window
    # Generate secrets with: openssl rand -hex 32
    PUBLIC_URL=chat.example.com
    # NATS
    NATS_TOKEN=<generate-me>
    # Chatto
    CHATTO_NATS_EMBEDDED_ENABLED=false
    CHATTO_NATS_CLIENT_URL=nats://nats:4222
    CHATTO_NATS_CLIENT_AUTH_METHOD=token
    CHATTO_NATS_CLIENT_TOKEN=<same-as-NATS_TOKEN>
    CHATTO_WEBSERVER_URL=https://chat.example.com
    CHATTO_WEBSERVER_PORT=4000
    CHATTO_WEBSERVER_COOKIE_SIGNING_SECRET=<generate-me>
    CHATTO_CORE_ASSETS_SIGNING_SECRET=<generate-me>
    CHATTO_LOG_LEVEL=info
    # LiveKit
    CHATTO_LIVEKIT_ENABLED=true
    CHATTO_LIVEKIT_URL=wss://livekit.chat.example.com
    CHATTO_LIVEKIT_API_KEY=your-api-key
    CHATTO_LIVEKIT_API_SECRET=your-api-secret

    Replace all <generate-me> values with output from openssl rand -hex 32. Make sure NATS_TOKEN and CHATTO_NATS_CLIENT_TOKEN match, and the LiveKit key/secret match what’s in livekit.yaml.

  6. Start the stack

    Terminal window
    docker compose up -d

    Caddy will automatically obtain TLS certificates for both domains. Visit https://chat.example.com to create your first account.

Terminal window
# View logs
docker compose logs -f
# Logs for a specific service
docker compose logs -f chatto
# Update to latest images
docker compose pull && docker compose up -d
# Stop everything
docker compose down
# Stop and delete all data
docker compose down -v
VolumeContents
nats_dataNATS/JetStream streams, KV stores — all chat data lives here
caddy_dataTLS certificates from Let’s Encrypt
caddy_configCaddy 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.

Required for email verification and password reset. Add to .env:

Terminal window
CHATTO_SMTP_ENABLED=true
CHATTO_SMTP_HOST=smtp.example.com
CHATTO_SMTP_PORT=587
CHATTO_SMTP_USERNAME=your-username
CHATTO_SMTP_PASSWORD=your-password
CHATTO_SMTP_FROM=noreply@example.com

For notifications when the browser is closed. Generate VAPID keys with npx web-push generate-vapid-keys, then add to .env:

Terminal window
CHATTO_PUSH_ENABLED=true
CHATTO_PUSH_VAPID_PUBLIC_KEY=BNx...
CHATTO_PUSH_VAPID_PRIVATE_KEY=...
CHATTO_PUSH_VAPID_SUBJECT=mailto:admin@example.com

The official Chatto Docker image includes ffmpeg. To enable video transcoding, add to .env:

Terminal window
CHATTO_VIDEO_ENABLED=true

If you don’t need voice and video calls, simplify the stack by:

  1. Removing the livekit service and its depends_on references from compose.yml
  2. Deleting the livekit.yaml file
  3. Removing the livekit.* block from the Caddyfile
  4. Removing the CHATTO_LIVEKIT_* variables from .env

You won’t need the livekit.* subdomain or the UDP port range.

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: 5349

Expose the TURN port in compose.yml under the livekit service:

ports:
- "50000-50200:50000-50200/udp"
- "5349:5349/tcp" # TURN/TLS relay

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:ro

Create turnserver.conf:

listening-port=3478
tls-listening-port=443
realm=chat.example.com
# Static credentials (must match LiveKit config)
user=livekit:your-turn-password
lt-cred-mech
# TLS (use Caddy's certificates or provide your own)
cert=/path/to/cert.pem
pkey=/path/to/key.pem

Then 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-password

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 orderdepends_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.