Self-host Loupe
Run your own signaling and TURN server on a $5/month VPS. Your devices, your metadata, your firewall. About 20 minutes from a fresh VM to a working deployment.
theloupe.team endpoint is fine
for getting started, but the signaling server does see connection metadata (IPs,
timing). If you don't want anyone — including Loupe — to see that, run your own. You
still need a Mac to host a session and an iPhone to control it; only the relay is
yours.
What you need
- A Linux VPS with a public IPv4 address. $5/month tiers from Hetzner, DigitalOcean, or OVH are plenty.
- A domain pointing an A record to that VPS (we'll use
signaling.example.comas a placeholder). - Ports
80,443, and3478reachable from the internet. UDP and TCP. - Docker + Docker Compose on the host.
1. Clone the repository
git clone https://github.com/bigbadboy1010/loupe.git
cd loupe/loupe-signaling
2. Configure your environment
cp .env.example .env
$EDITOR .env
Set at minimum:
TURN_SECRET="$(openssl rand -base64 48)"
TURN_HOST=signaling.example.com
TURN_REALM=signaling.example.com
TURN_EXTERNAL_IP=203.0.113.10
SERVE_SITE=true
TURN_SECRET must be the same value for the signaling service and coturn.
Both containers share it via the same .env file.
3. Reverse proxy with Caddy (automatic HTTPS)
Loupe is designed to run behind Caddy for automatic Let's Encrypt certificates. Drop a
Caddyfile next to your docker-compose.yml:
signaling.example.com {
reverse_proxy signaling:8080
}
Then in docker-compose.yml, add Caddy as a service:
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "3478:3478/udp"
- "3478:3478/tcp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
4. Boot it
docker compose up -d
docker compose logs -f signaling
Wait for "server listening". Then verify:
curl https://signaling.example.com/healthz
Expected output:
{"status":"ok","activeSessions":0,"pairingCodes":0,...}
5. Point the Loupe apps at your server
In the macOS Host app and iOS Controller app, replace the default
signaling URL wss://signaling.theloupe.team/ws with
wss://signaling.example.com/ws (your own signaling host).
The apps accept this in their settings.
6. (Recommended) Put the waitlist behind a different domain
If you want to host the marketing site too, just keep SERVE_SITE=true. If
you'd rather keep the waitlist off this server, set SERVE_SITE=false and
point your marketing domain's Caddy config at a static site elsewhere (Netlify,
Cloudflare Pages).
Maintenance
Updating
git pull
docker compose build
docker compose up -d
Rotating the TURN secret
Rotate TURN_SECRET every 90 days. Existing in-flight sessions will need to
reconnect once (the controller auto-reconnects within 5–10 seconds; see
docs/stability-reconnect.md).
NEW_SECRET="$(openssl rand -base64 48)"
sed -i.bak "s|^TURN_SECRET=.*|TURN_SECRET=$NEW_SECRET|" .env
docker compose up -d
Backups
There is no application state to back up. Sessions live in memory; pairing codes expire
in 5 minutes; the waitlist is the only persistent data. tar czf backup.tgz
data/waitlist.jsonl .env is enough.
Troubleshooting
STUN/TURN port unreachable
Check the firewall on your VPS. Both UDP and TCP need to be open on
3478. The relay ports (49152–65535 by default) need UDP too.
Caddy won't issue a certificate
Make sure the A record resolves and that port 80 is reachable from the internet (Let's Encrypt needs it for the HTTP-01 challenge).
Connections establish but video never appears
Almost always TURN. Run docker compose logs coturn and look for
"allocation reached" or "quota reached" messages. The default
coturn config has generous limits; this usually means TURN_EXTERNAL_IP is
wrong.
Found an issue? Open an issue
or email hello@theloupe.team.