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

  • 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 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

  1. Create a tunnel and copy its token
  2. Download the files
  3. Fill in your .env
  4. Map the subdomains
  5. Start the stack and connect core-api to the chat module
  6. Forward the two voice ports
  7. 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
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
[email protected]

# 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
[email protected]
SMTP_PASSWORD=         # the 16-char App Password
[email protected]

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

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

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.

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

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

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 & 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:
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 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.