Skip to content

TLS and reverse proxy

Caddy, nginx and Traefik, done right

Edit this page
On 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.

RequirementWhy
BETTER_AUTH_URL matches exactlyBetter-Auth signs callbacks against it. A mismatch makes sign-in redirect somewhere wrong
Buffering off for /api/sseServer-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/pollThe 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:

caddyfile
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

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

yaml
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=1ms

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

yaml
server:
  ports:
    - "127.0.0.1:3000:3000"

And set the public URL:

bash
BETTER_AUTH_URL=https://loomscope.example.com

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

yaml
daemon:
  volumes:
    - /etc/ssl/certs/internal-ca.crt:/etc/ssl/certs/internal-ca.crt:ro

There 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.