Put your home server online through Cloudflare without opening any inbound ports — except the two that voice/video physically needs. This is the easiest path if your domain is on Cloudflare. Allow ~20 minutes.
First, what a tunnel actually is
LetsChat runs as a handful of services on your server, but they're only reachable on your home network. The old way to expose them is to open ports 80/443on your router and point your domain at your home IP — which publishes your address and opens your machine to the whole internet.
A Cloudflare Tunnel flips that around. A small program — cloudflared — runs next to LetsChat and makes one outbound connection up to Cloudflare, then holds it open. When someone visits app.yourdomain.com, the request arrives at Cloudflare and is piped back down that connection to your server. You never open an inbound port, your home IP is never published, and Cloudflare gives you HTTPS for free.
You'll point four subdomains at four services through that tunnel:
authlogin, tokens & first-run discovery
chatrealtime database
filesuploads & downloads
lkvoice signalling
Plus a fifth — app — if you want the browser client (more on that just below).
Desktop app or browser — your call
LetsChat ships two ways to connect, and this stack serves both:
Desktop app (macOS / Windows / Linux) — users paste yourauth.yourdomain.com address into the "pick a server" screen and it auto-discovers the rest.
Browser — the stack includes a hosted web client at app.yourdomain.com. Open that URL in any modern browser and you go straight to login. No install required, voice and video included.
Compose builds the server address from AUTH_DOMAIN, and Caddy serves it to the browser at /config.js. The browser is locked to this instance and skips the setup screen. Hosting it is optional — leave APP_DOMAIN empty and omit the app route. This guide sets up both; the browser-only bits are clearly marked.
The plan
Create a tunnel and copy its token
Download the files
Fill in your .env
Map the subdomains
Start the stack and connect core-api to the chat module
Forward the two voice ports
Open the app — desktop or browser
Before you start
A domain whose DNS is managed by Cloudflare (free plan is fine).
Cloudflare Zero Trust enabled on your account (free).
An Ubuntu server with Docker Engine 25+ and the Compose plugin 2.23.1+ (for health checks and inline proxy configuration).
Admin access to your home router (for the two voice ports in Step 6).
30-second voice check. Voice needs a real public IP. Run this on the server and compare the result to the WAN / Internet IP shown on your router's status page:
terminal
curl -4 ifconfig.me
Same IP → you're good. Different → your ISP puts you behind CGNAT; text chat and everything else still works, but voice will need a relay (see Voice behind CGNAT at the end).
1
Create the tunnel & copy the token
In the Cloudflare dashboard: Zero Trust → Networks → Tunnels → Create a tunnel → Cloudflared. Name it (e.g. letschat) and save.
The next screen shows an install command containing --token <LONG-STRING>.Copy that token — it's the connector's password and the only thing you need from this page. Leave the tab open; you'll add the subdomains in Step 4.
2
Download the files
On the server, in a fresh folder:
terminal
mkdir letschat && cd letschat
# the stack + the cloudflared connector overlay
wget https://raw.githubusercontent.com/da-stoaz/letschat/main/docker-compose.prod.base.yml
wget https://raw.githubusercontent.com/da-stoaz/letschat/main/docker-compose.prod.tunnel.yml
# the LiveKit config
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
# your settings file
wget -O .env https://raw.githubusercontent.com/da-stoaz/letschat/main/.env.production.tunnel.example
Open .env and set every field in this block — the stack won't start without POSTGRES_PASSWORD, and you can't sign in without the admin account. Replace example.com with your domain throughout:
.env
# Secrets
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, e.g. letschat-minio
MINIO_SECRET_KEY= # openssl rand -hex 32
CLOUDFLARE_TUNNEL_TOKEN= # the token you copied in Step 1
# 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 hostnames — enter each once, without a scheme or path.
# Compose builds the public URLs for discovery, files and the browser.
AUTH_DOMAIN=auth.example.com
CHAT_DOMAIN=chat.example.com
FILES_DOMAIN=files.example.com
LIVEKIT_DOMAIN=lk.example.com
Hosting the browser client (optional)
Want people to use LetsChat from a browser at app.example.com? It connects to the auth hostname on its own; add the app route in Step 4, and set:
.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.
Email
By default LetsChat sends confirmation emails, so it needs SMTP. Using a Gmail account? Enable 2-Step Verification, create an App Password (Google Account → Security → App passwords — a 16-char code, not your normal password), and set:
.env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=you@gmail.com
SMTP_PASSWORD= # the 16-char App Password
EMAIL_FROM_ADDRESS=you@gmail.com
Everything else in the file has a working default and is documented inline — leave it alone unless you have a reason. The publisher token is read automatically; no SPACETIMEDB_SERVICE_TOKEN input is required.
4
Map the subdomains
Back on your tunnel's page, open Public Hostnames and add one entry per subdomain. The connector runs inside Docker next to the services, so the targets are the internal service names (not localhost):
Start the stack 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.
Always pull first. Docker otherwise reuses any older :latest images already on the server, and an old chat module then creates the database without its admin bootstrap.
The module-init container publishes the SpacetimeDB schema automatically once the database is healthy — no manual spacetime publish. Watch it finish with:
terminal
docker logs -f 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.
6
Forward the two voice ports
This is the one step the tunnel can't do — voice media must reach your server directly. First find the server's LAN IP and give it a static DHCP reservation in your router so it never changes:
terminal
ip -4 -o addr show scope global | awk '{print $2, $4}' # e.g. eth0 192.168.1.50/24
Then, in your router's Port Forwarding section, forward both of these to that LAN IP:
44382/udp — the voice/video media (almost every call uses only this)
44381/tcp — fallback for callers on networks that block UDP
Don't forward 44380 — that's signalling, and it already goes through the tunnel. A dynamic public IP is fine; only an IP change mid-call needs a LiveKit restart to recover.
7
Open the app — desktop or browser
Confirm the public endpoints answer (from anywhere):
Desktop app — enter auth.example.com as the server; it auto-discovers the rest.
Browser — open https://app.example.com; it connects to this instance automatically.
Sign in with the admin account from Step 3, then make a test voice call between two clients.
Alternative setup
Already running cloudflared (or nginx/Caddy) on this host?
If you already run a connector natively on the server for other apps, don't run a second one and don't use the bundled overlay. Two changes to the path above:
Step 1 & 5: skip the bundled connector. Start with the base file only —docker compose -f docker-compose.prod.base.yml up -d — and in Step 2 you don't needdocker-compose.prod.tunnel.yml. Leave CLOUDFLARE_TUNNEL_TOKEN blank; your existing connector has its own credentials.
Step 4: add the hostnames to your existing tunnel, pointing at the host'slocalhost ports instead of Docker names:
Everything else (the .env, LiveKit secret, voice-port forwarding) is identical. Same idea for nginx / Caddy / Traefik on the host — terminate TLS there and proxy to thoselocalhost ports, keeping chat and lk as WebSocket upgrades.
If the voice check failed
Voice behind CGNAT
If your public IP and your router's WAN IP differ, your ISP has you behind carrier-grade NAT — port forwarding can't reach you, so voice won't work directly (text, chat and files are unaffected). The fix is a TURN relay both sides connect outbound to:
TURN on a cheap public-IP VPS — relays media through a box with a stable, routable IP. All call media flows through it, so size its bandwidth accordingly.
Cloudflare Realtime (TURN) — a managed relay at Cloudflare's edge, billed per GB.