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.

Why self-host? The public 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

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.