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 HTTPSClassic Reverse ProxyAuto TLS via ACME
Requirements (read first)
Ubuntu host with Docker Engine 25+ and the Compose plugin 2.23.1+ (for health checks and inline proxy configuration).
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:
authlogin, tokens & first-run discovery
chatrealtime database
filesuploads & downloads
lkvoice signalling
appbrowser client (optional)
Step 0: Get shared files
Download the shared base compose file, the LiveKit config and the SpacetimeDB 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
# the SpacetimeDB server config (WebSocket keepalive) — required: without it
# Docker mounts an empty folder and the database never starts
mkdir spacetimedb
wget -O spacetimedb/config.prod.toml https://raw.githubusercontent.com/da-stoaz/letschat/main/spacetimedb/config.prod.toml
Every service — the browser client included — is a pre-built image that GitHub Actions publishes with each release. Nothing is compiled on your server. To upgrade later, download the compose files again (they change with releases) and pull.
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 hostnames
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
[email protected]
# Public hostnames — enter each once, without a scheme or path.
# Compose builds the URLs; Caddy and clients share these settings.
AUTH_DOMAIN=auth.example.com
CHAT_DOMAIN=chat.example.com
FILES_DOMAIN=files.example.com
LIVEKIT_DOMAIN=lk.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 protected]
Hosting the browser client (optional)
For the browser client at app.example.com, set its hostname below. It supplies both Caddy routing and desktop invite links. Leave APP_DOMAIN empty to omit the browser site.
.env
# Browser site and desktop invite links; no separate URL setting.
APP_DOMAIN=app.example.com
# DB WebSocket compression in the browser: "gzip" (default) or "none".
VITE_WEB_WS_COMPRESSION=gzip
Leave MINIO_CORS_ALLOW_ORIGIN=* as the example file has it. Narrowing it tohttps://app.example.com blocks every upload from the desktop app, whose origin istauri://localhost / http://tauri.localhost.
Everything else in .env already has a working default and is documented inline — LIVEKIT_API_KEY, POSTGRES_USER/POSTGRES_DB, registration policy, rate limits, client-version pins. Leave them unless you have a reason to change them. The publisher token is read automatically; no SPACETIMEDB_SERVICE_TOKEN input is required.
Step 4: Pull images, start, and connect core-api to the chat module
Your .env ships with LETSCHAT_VERSION=latest. Pin it to a release (e.g. LETSCHAT_VERSION=1.2.4) to make upgrades explicit. Before an upgrade, save the current deployment files and keep the deployed images. To roll back, restore those files together with .env, then run docker compose … up -d --pull never. See the upgrade and rollback guide.
module-init automatically publishes the SpacetimeDB module once the database is healthy. Watch its progress with:
terminal
docker logs letschat-module-init
Check automatic setup
core-api reads the publisher token, pins its trusted issuer and registers the archive worker automatically. Until the issuer is pinned, new chat registrations are rejected. No token copying or manual reducer calls are needed. Check setup and actual replication:
terminal
docker logs letschat-core-api
# Expect: Pinned SpacetimeDB trusted issuer to http://core-api:8787.
# Expect: Registered archive-worker identity ...
docker compose -f docker-compose.prod.base.yml exec postgres \
psql -U letschat -d archive -c 'SELECT count(*) FROM archive_user;'
# The module-owner row should already be replicated.
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: