Beginner-first production tutorial (Ubuntu + Docker Compose)

Self-host the LetsChat backend

LetsChat is not a single server. It is a small backend stack: chat state, auth, voice signaling, file storage, and startup discovery. This guide explains what each part does, why it matters, and how to deploy it in production.

SpacetimeDB Auth Service LiveKit MinIO Discovery URL

What Runs In Production (And Why)

SpacetimeDB

Real-time chat/game state backend. Required for channels, messages, membership, and sync.

Auth Service

Login/session API and token minting. Required for user authentication and LiveKit token creation.

LiveKit

Voice/media signaling and transport. Required for voice channels and real-time audio.

MinIO

S3-compatible object storage. Required for uploads, attachments, and download URLs.

Discovery Endpoint (`/.well-known/letschat.json`)

Lets app startup auto-discover your backend from one URL (for example `https://connect.example.com`). Without this, users must paste a full join link manually.

Choose Your Deployment Track

Deployment Interface

Production is split into one shared compose base plus one track overlay:

# Track A: Tunnel
cp .env.production.tunnel.example .env
docker compose -f docker-compose.prod.base.yml -f docker-compose.prod.tunnel.yml up -d --build

# Track B: Caddy
cp .env.production.caddy.example .env
docker compose -f docker-compose.prod.base.yml -f docker-compose.prod.caddy.yml up -d --build

Track A: Cloudflare Tunnel

Requirements (Read First)

  • Ubuntu host with Docker Engine + Compose plugin.
  • Cloudflare-managed domain plus Zero Trust access (to create a tunnel token).
  • Router access for LiveKit media port forwarding (`44381/tcp+udp`, `44382/udp`).
  • Public IPv4 (or ISP config) for reliable media. CGNAT can break voice.
docker --version
docker compose version
curl -4 ifconfig.me

How This Track Works

Cloudflare Tunnel proxies HTTP/WebSocket services (`auth/chat/files/connect`) from Cloudflare to your Docker network.

LiveKit media is different: raw UDP/TCP media must still be reachable directly on your host via router forwarding.

Step 1: DNS + Tunnel Ingress

Create these hostnames in Cloudflare and configure tunnel ingress:

  • `auth.example.com` -> `http://auth-service:44301`
  • `chat.example.com` -> `http://spacetimedb:3000` (WebSocket enabled)
  • `files.example.com` -> `http://minio:44390`
  • `connect.example.com` -> `http://discovery:80`
  • `lk.example.com` -> DNS to your host public IP (not tunnelled media)

Step 2: Configure Secrets + Endpoints

cp .env.production.tunnel.example .env

# generate secrets and paste into .env
openssl rand -hex 32
openssl rand -base64 32

Set all required values, especially `CLOUDFLARE_TUNNEL_TOKEN` and `DISCOVERY_*` fields used by startup auto-discovery.

Step 3: LiveKit Production Config

In `livekit/config.prod.yaml`, match API keys with `.env` and keep media ports aligned.

port: 44380
rtc:
  use_external_ip: true
  tcp_port: 44381
  udp_port: 44382
keys:
  letschat-prod: <same as LIVEKIT_API_SECRET>

Step 4: Start Services

docker compose -f docker-compose.prod.base.yml -f docker-compose.prod.tunnel.yml up -d --build
docker compose -f docker-compose.prod.base.yml -f docker-compose.prod.tunnel.yml ps
curl -i http://127.0.0.1:44305/.well-known/letschat.json

Step 5: Publish SpacetimeDB Module

spacetime publish --server http://127.0.0.1:44300 letschat --module-path server --yes

Step 6: Validate Public Endpoints

curl -i https://auth.example.com/health
curl -i https://files.example.com/minio/health/live
curl -i https://connect.example.com/.well-known/letschat.json

Confirm router forwarding to host: `44381/tcp`, `44381/udp`, `44382/udp`.

Step 7: Client Onboarding

Users can enter `https://connect.example.com` in startup and LetsChat will auto-discover backend URLs.

Discovery JSON example:

{
  "spacetimedb": "wss://chat.example.com",
  "auth": "https://auth.example.com",
  "livekit": "ws://lk.example.com:44380",
  "database": "letschat"
}

Use `wss://lk...` only if you add TLS in front of LiveKit signaling.

Track B: Caddy Reverse Proxy

Requirements (Read First)

  • Ubuntu host with Docker Engine + Compose plugin.
  • Cloudflare DNS records set to your host public IP (DNS-only mode for ACME issuance).
  • Router/firewall allows `80/tcp` and `443/tcp` to host (for Caddy TLS/HTTP).
  • LiveKit media forwarding still required: `44381/tcp+udp`, `44382/udp`.

How This Track Works

Caddy runs on your host, terminates HTTPS, and routes by hostname to internal Docker services.

This removes Cloudflare Tunnel complexity, but your host directly exposes `80/443` to the internet.

Step 1: DNS + Network Setup

Point these DNS records to your host public IP:

  • `auth.example.com`
  • `chat.example.com`
  • `files.example.com`
  • `lk.example.com`
  • `connect.example.com`

Open ports: `80/tcp`, `443/tcp`, `44381/tcp+udp`, `44382/udp`.

Step 2: Configure Secrets + Domains

cp .env.production.caddy.example .env
openssl rand -hex 32
openssl rand -base64 32

Set `*_DOMAIN` and `DISCOVERY_*` values. `CONNECT_DOMAIN` is required for startup auto-discovery route.

Step 3: LiveKit Production Config

Same key/port alignment rules as Tunnel track.

Step 4: Start Services

docker compose -f docker-compose.prod.base.yml -f docker-compose.prod.caddy.yml up -d --build
docker compose -f docker-compose.prod.base.yml -f docker-compose.prod.caddy.yml ps

Step 5: Publish SpacetimeDB Module

spacetime publish --server http://127.0.0.1:44300 letschat --module-path server --yes

Step 6: Validate Public Endpoints

curl -i https://auth.example.com/health
curl -i https://files.example.com/minio/health/live
curl -i https://connect.example.com/.well-known/letschat.json

Step 7: Client Onboarding

Users can enter `https://connect.example.com` in startup. Discovery JSON should point to your public endpoints:

{
  "spacetimedb": "wss://chat.example.com",
  "auth": "https://auth.example.com",
  "livekit": "wss://lk.example.com",
  "database": "letschat"
}

Common Mistakes

Problem Likely cause Fix
Voice joins but no audio LiveKit media ports not forwarded Forward `44381/tcp+udp` and `44382/udp` to host
Setup discovery fails `connect.` not routed to discovery container Tunnel: `connect -> discovery:80`; Caddy: set `CONNECT_DOMAIN` correctly
Upload/download errors Wrong `MINIO_PUBLIC_ENDPOINT` Set to public `https://files...` endpoint clients can reach
Caddy cert issuance fails Cloudflare proxy enabled or closed 80/443 Use DNS-only records and open `80/tcp`, `443/tcp`
LiveKit setup fails immediately Wrong signaling scheme (`ws://` vs `wss://`) Tunnel often uses `ws://lk...:44380`; Caddy usually uses `wss://lk...`
Spacetime connect timeout Module not published to production SpacetimeDB Run `spacetime publish --server http://127.0.0.1:44300 ...`