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/443
on 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 your
auth.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.
The browser client is baked at build time with your server address
(VITE_WEB_CONNECT_URL), so it's locked to this one instance and skips the setup
screen entirely. Hosting it is optional — leave app out and the desktop app still works.
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
Set the LiveKit secret
Map the subdomains
Start the stack
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 + the Compose plugin.
Admin access to your home router (for the two voice ports in Step 7).
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 5.
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
# 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
# Your public URLs (auth.<domain> also serves the discovery document)
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
Hosting the browser client (optional)
Want people to use LetsChat from a browser at app.example.com? Set these three.
Skip them and only the desktop app is served — everything else is unaffected.
.env
# Browser client — baked into the bundle at build time, so it auto-connects
# to THIS instance. Point it at auth.<domain>, which serves discovery.
VITE_WEB_CONNECT_URL=https://auth.example.com
# Let the browser fetch presigned download URLs from MinIO. Pin to the web
# origin (or "*" to allow any).
MINIO_CORS_ALLOW_ORIGIN=https://app.example.com
# DB WebSocket compression in the browser: "gzip" (default) or "none".
VITE_WEB_WS_COMPRESSION=gzip
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. (One to know about: SPACETIMEDB_SERVICE_TOKEN is optional;
blank is fine — it only makes one admin-panel card editable.)
4
Set the LiveKit secret
Open livekit/config.prod.yaml and paste the same secret you put in .env:
Leave the rest of the file (ports, use_external_ip: true) as-is.
5
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):
docker compose -f docker-compose.prod.base.yml -f docker-compose.prod.tunnel.yml up -d
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
7
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.
8
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 & 6: 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 need
docker-compose.prod.tunnel.yml. Leave CLOUDFLARE_TUNNEL_TOKEN blank;
your existing connector has its own credentials.
Step 5: add the hostnames to your existing tunnel, pointing at the host's
localhost 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 those
localhost 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.