TLS and reverse proxy
Caddy, nginx and Traefik, done right
Edit this pageOn this page
Loomscope ships no TLS termination. The compose file publishes port 3000 in cleartext, which is fine for a laptop and wrong for anything else. Put a reverse proxy in front, terminate TLS there, and stop publishing 3000.
What the proxy has to get right
Three things, and two of them are easy to miss because they fail quietly.
| Requirement | Why |
|---|---|
BETTER_AUTH_URL matches exactly | Better-Auth signs callbacks against it. A mismatch makes sign-in redirect somewhere wrong |
Buffering off for /api/sse | Server-Sent Events arrive in one lump at the end of the stream if the proxy buffers |
A long read timeout on /api/v1/daemon/jobs/poll | The daemon long-polls for up to 60 seconds. A 30-second proxy timeout turns that into a reconnect loop |
Caddy
The shortest correct configuration, including automatic certificates:
loomscope.example.com {
reverse_proxy localhost:3000 {
flush_interval -1
transport http {
read_timeout 5m
}
}
}flush_interval -1 disables response buffering, which is what SSE needs.
nginx
server {
listen 443 ssl http2;
server_name loomscope.example.com;
ssl_certificate /etc/letsencrypt/live/loomscope.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/loomscope.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long-poll and SSE both need this.
proxy_read_timeout 3600s;
proxy_buffering off;
proxy_cache off;
}
}X-Forwarded-For matters beyond aesthetics: the audit log records the
address behind every privileged action, and without this header every entry
reads as the proxy.
Traefik
labels:
- traefik.enable=true
- traefik.http.routers.loomscope.rule=Host(`loomscope.example.com`)
- traefik.http.routers.loomscope.tls.certresolver=letsencrypt
- traefik.http.services.loomscope.loadbalancer.server.port=3000
- traefik.http.services.loomscope.loadbalancer.responseForwarding.flushInterval=1msThen stop publishing port 3000
Once a proxy is in place, bind the container to loopback and let the proxy
reach it. In infra/docker-compose.yml:
server:
ports:
- "127.0.0.1:3000:3000"And set the public URL:
BETTER_AUTH_URL=https://loomscope.example.comRestart the control plane after changing it. If sign-in starts redirecting to
http://localhost:3000 after a proxy goes in, this variable is the reason
every time.
Daemons behind the proxy
Point LOOMSCOPE_SERVER_URL at the public HTTPS URL. Daemons make only
outbound connections, so nothing needs to be opened towards them — but they
do need to trust the certificate chain. With a private CA, mount your root
into the daemon container:
daemon:
volumes:
- /etc/ssl/certs/internal-ca.crt:/etc/ssl/certs/internal-ca.crt:roThere is no flag to skip certificate verification on the daemon's own connection to the control plane, and there should not be: that connection carries the daemon's API key.
Client certificates and mTLS
Not supported by the daemon's HTTP client today. If your environment requires
mutual TLS between the daemon and the control plane, terminate it at a
sidecar on the daemon host and point LOOMSCOPE_SERVER_URL at that.