SpacetimeDB
Real-time chat/game state backend. Required for channels, messages, membership, and sync.
Beginner-first production tutorial (Ubuntu + Docker Compose)
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
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.
Track A: Cloudflare Tunnel
What: Routes `auth/chat/files/connect` over Cloudflare Tunnel; LiveKit media remains direct via forwarded ports.
Why: Keeps web services off direct public IP while still supporting voice traffic.
Best for: Home servers where you want Cloudflare ingress control without exposing 80/443 directly.
Track B: Caddy Reverse Proxy
What: Caddy terminates TLS on your host and proxies `auth/chat/files/lk/connect` locally.
Why: Simple standard architecture with fewer external moving parts.
Best for: Operators comfortable exposing 80/443 and managing direct DNS to host IP.
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 docker --version
docker compose version
curl -4 ifconfig.me 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.
Create these hostnames in Cloudflare and configure tunnel ingress:
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.
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> 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 spacetime publish --server http://127.0.0.1:44300 letschat --module-path server --yes 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`.
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.
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.
Point these DNS records to your host public IP:
Open ports: `80/tcp`, `443/tcp`, `44381/tcp+udp`, `44382/udp`.
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.
Same key/port alignment rules as Tunnel track.
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 spacetime publish --server http://127.0.0.1:44300 letschat --module-path server --yes 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 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"
} | 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. | 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 ...` |