Cloudflare Tunnel

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:

  • auth login, tokens & first-run discovery
  • chat realtime database
  • files uploads & downloads
  • lk voice 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

  1. Create a tunnel and copy its token
  2. Download the files
  3. Fill in your .env
  4. Set the LiveKit secret
  5. Map the subdomains
  6. Start the stack
  7. Forward the two voice ports
  8. 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
3

Fill in your .env

Generate the secrets first:

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

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:

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

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):

cloudflare public hostnames
auth.example.com   ->  http://core-api:8787       # also serves /.well-known discovery
chat.example.com   ->  http://spacetimedb:3000     # enable WebSocket
files.example.com  ->  http://minio:44390
lk.example.com     ->  http://livekit:44380        # enable WebSocket
app.example.com    ->  http://web:80               # browser client (omit if not hosting it)
6

Start the stack

terminal
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):

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

Then pick how you connect:

  • 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:
cloudflare public hostnames
auth.example.com   ->  http://localhost:8787
chat.example.com   ->  http://localhost:44300     # enable WebSocket
files.example.com  ->  http://localhost:44390
lk.example.com     ->  http://localhost:44380     # enable WebSocket
app.example.com    ->  http://localhost:44310     # browser client (omit if not hosting it)

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.