Path B: Caddy Reverse Proxy

Caddy runs on your host, terminates HTTPS automatically, and routes by hostname to internal Docker services. Simpler moving parts than a tunnel — but your host directly exposes ports 80 and 443.

Direct HTTPS Classic Reverse Proxy Auto TLS via ACME

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).
  • A routable public IPv4, not CGNAT. This track exposes the host directly, so CGNAT breaks everything, not just voice. Step 2 shows how to check.
  • Router/firewall forwards to the host's LAN IP: 80/tcp + 443/tcp (Caddy TLS/HTTP) and 44382/udp + 44381/tcp (LiveKit media). All a manual router step.

How this path 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.

You'll publish these hostnames — five if you host the browser client, four without it:

  • auth login, tokens & first-run discovery
  • chat realtime database
  • files uploads & downloads
  • lk voice signalling
  • app browser client (optional)

Step 0: Get shared files

Download the shared base compose file and LiveKit config:

terminal
mkdir letschat && cd letschat

wget https://raw.githubusercontent.com/da-stoaz/letschat/main/docker-compose.prod.base.yml

mkdir livekit
wget -O livekit/config.prod.yaml \
  https://raw.githubusercontent.com/da-stoaz/letschat/main/livekit/config.prod.yaml

All images are pre-built — no Rust or Node toolchains needed on the server (the browser client compiles itself in the web container on first start).

Step 1: Download Path B files

Inside the letschat directory:

terminal
wget https://raw.githubusercontent.com/da-stoaz/letschat/main/docker-compose.prod.caddy.yml

wget -O .env \
  https://raw.githubusercontent.com/da-stoaz/letschat/main/.env.production.caddy.example

mkdir -p deploy/caddy
wget -O deploy/caddy/Caddyfile \
  https://raw.githubusercontent.com/da-stoaz/letschat/main/deploy/caddy/Caddyfile

Step 2: DNS + network setup

Point these A records to your host public IP (DNS-only, no Cloudflare proxy):

  • auth.example.com
  • chat.example.com
  • files.example.com
  • lk.example.com
  • app.example.com — only if you host the browser client

Give the server a static DHCP reservation, then forward these on your router/firewall to its LAN IP:

  • 80/tcp and 443/tcp → Caddy (HTTP + TLS for all web services)
  • 44382/udp (primary media) and 44381/tcp (fallback) → LiveKit media

Port 44380 (LiveKit signalling) does not need forwarding — Caddy proxies it on 443 as wss://lk.example.com.

Step 3: Configure secrets and domains

Open .env and replace every placeholder. Generate secrets with:

terminal
openssl rand -hex 32      # for AUTH_JWT_SECRET, MINIO_SECRET_KEY
openssl rand -base64 32   # for LIVEKIT_API_SECRET

Every field below must be set — the stack will not start without POSTGRES_PASSWORD, and you cannot sign in without the bootstrap admin. Grouped by purpose:

.env
# Secrets — generate each one
AUTH_JWT_SECRET=            # openssl rand -hex 32
POSTGRES_PASSWORD=         # openssl rand -hex 32
LIVEKIT_API_SECRET=        # openssl rand -base64 32
MINIO_ACCESS_KEY=          # any username you choose
MINIO_SECRET_KEY=          # openssl rand -hex 32

# First admin account — created on first start, change the password after
ADMIN_BOOTSTRAP_USERNAME=admin
ADMIN_BOOTSTRAP_PASSWORD=  # a strong password
ADMIN_BOOTSTRAP_EMAIL=you@example.com

# Public endpoints — your domains (auth.<domain> also serves discovery)
MINIO_PUBLIC_ENDPOINT=https://files.example.com
DISCOVERY_SPACETIMEDB_URI=wss://chat.example.com
DISCOVERY_AUTH_URL=https://auth.example.com
DISCOVERY_LIVEKIT_URL=wss://lk.example.com

# Caddy virtual-host names (must match the DNS records above)
AUTH_DOMAIN=auth.example.com
CHAT_DOMAIN=chat.example.com
FILES_DOMAIN=files.example.com
LIVEKIT_DOMAIN=lk.example.com
APP_DOMAIN=app.example.com

# Outbound email — required because EMAIL_SENDER defaults to smtp and
# email confirmation is on by default
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
EMAIL_FROM_ADDRESS=no-reply@example.com

Hosting the browser client (optional)

For the browser client at app.example.com, also set these. Skip them (and the APP_DOMAIN above) to serve the desktop app only.

.env
# Baked into the browser bundle at build time so it auto-connects to THIS
# instance. Point at auth.<domain>, which serves the discovery document.
VITE_WEB_CONNECT_URL=https://auth.example.com

# Let the browser fetch presigned download URLs from MinIO.
MINIO_CORS_ALLOW_ORIGIN=https://app.example.com

# DB WebSocket compression in the browser: "gzip" (default) or "none".
VITE_WEB_WS_COMPRESSION=gzip

Everything else in .env already has a working default and is documented inline — LIVEKIT_API_KEY (must match livekit/config.prod.yaml), POSTGRES_USER/POSTGRES_DB, registration policy, rate limits, client-version pins. Leave them unless you have a reason to change them.

Step 4: Configure LiveKit

Open livekit/config.prod.yaml and replace the key/secret placeholder so it matches your .env:

livekit/config.prod.yaml
keys:
  letschat-prod: <paste LIVEKIT_API_SECRET here>

The rest of the file (ports, use_external_ip: true) can stay as-is for most home server setups.

Step 5: Pull images and start

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

module-init automatically publishes the SpacetimeDB module once the database is healthy. Watch its progress with:

terminal
docker logs letschat-module-init

Step 6: Validate public endpoints

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

Step 7: Client onboarding

Desktop: users enter https://auth.example.com in the setup screen. Browser: users open https://app.example.com and go straight to login. Both read the same discovery document, which should point to your public endpoints:

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