Reverse proxy
Configure Traefik, Nginx, or Caddy in front of the Watchflare Hub.
The Hub exposes two ports with different proxying requirements:
| Port | Protocol | Proxy type |
|---|---|---|
8080 | HTTP | Standard reverse proxy — TLS termination here |
50051 | gRPC / TLS 1.3 | TCP passthrough — no TLS termination |
Warning
The gRPC port must be proxied at the TCP level, without TLS termination. Agents pin the Hub’s CA certificate at registration. If the proxy presents a different certificate, every agent will refuse to connect.
Traefik
Tested: ✅ Traefik v3
Traefik needs two routes: an HTTP reverse proxy for the dashboard, and a TCP passthrough for gRPC.
Static config
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
grpc:
address: ":50051"
certificatesResolvers:
letsencrypt:
acme:
email: you@example.com
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web Restart Traefik after editing the static config. Dynamic config changes are hot-reloaded.
Dynamic config
http:
routers:
watchflare-http:
entryPoints: [web]
rule: "Host(`watchflare.example.com`)"
middlewares: [redirect-https]
service: watchflare-http
watchflare-https:
entryPoints: [websecure]
rule: "Host(`watchflare.example.com`)"
tls:
certResolver: letsencrypt
service: watchflare-http
middlewares:
redirect-https:
redirectScheme:
scheme: https
permanent: true
services:
watchflare-http:
loadBalancer:
servers:
- url: "http://HUB_IP:8080"
tcp:
routers:
watchflare-grpc:
entryPoints: [grpc]
rule: "HostSNI(`*`)"
tls:
passthrough: true
service: watchflare-grpc
services:
watchflare-grpc:
loadBalancer:
servers:
- address: "HUB_IP:50051" Replace HUB_IP with the IP or hostname of the server running the Hub, and watchflare.example.com with your domain.
Docker Compose labels
If Traefik and the Hub run in the same Compose stack:
services:
traefik:
image: traefik:v3
ports:
- "80:80"
- "443:443"
- "50051:50051"
command:
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.grpc.address=:50051"
- "--certificatesresolvers.letsencrypt.acme.email=you@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
watchflare:
labels:
- "traefik.enable=true"
# HTTP → HTTPS
- "traefik.http.routers.watchflare.rule=Host(`watchflare.example.com`)"
- "traefik.http.routers.watchflare.entrypoints=websecure"
- "traefik.http.routers.watchflare.tls.certresolver=letsencrypt"
- "traefik.http.services.watchflare.loadbalancer.server.port=8080"
# gRPC TCP passthrough
- "traefik.tcp.routers.watchflare-grpc.entrypoints=grpc"
- "traefik.tcp.routers.watchflare-grpc.rule=HostSNI(`*`)"
- "traefik.tcp.routers.watchflare-grpc.tls.passthrough=true"
- "traefik.tcp.services.watchflare-grpc.loadbalancer.server.port=50051" Nginx
Tested: ⚠️ Not tested — configuration based on Nginx documentation
Nginx requires the stream module for TCP passthrough on the gRPC port (--with-stream at compile time, included in most Linux distributions).
# HTTPS reverse proxy (dashboard)
server {
listen 443 ssl;
server_name watchflare.example.com;
ssl_certificate /etc/letsencrypt/live/watchflare.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/watchflare.example.com/privkey.pem;
location / {
proxy_pass http://HUB_IP:8080;
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;
# Required for SSE (real-time dashboard updates)
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
}
}
server {
listen 80;
server_name watchflare.example.com;
return 301 https://$host$request_uri;
}
# TCP passthrough (gRPC — do not terminate TLS)
stream {
server {
listen 50051;
proxy_pass HUB_IP:50051;
}
} The stream {} block must be at the top level of nginx.conf, not inside an http {} block.
Warning
proxy_http_version 1.1, proxy_set_header Connection "", and proxy_buffering off are all required in the HTTP block. Without them, the SSE stream that drives real-time host status and metrics updates will be buffered and the dashboard will not update live.
Caddy
Tested: ⚠️ Not tested — configuration based on Caddy documentation
Caddy handles HTTPS and certificate renewal automatically. For the gRPC TCP passthrough, the caddy-l4 plugin is required.
HTTP (dashboard) — standard Caddyfile, no plugin needed:
watchflare.example.com {
reverse_proxy HUB_IP:8080
} gRPC TCP passthrough — requires caddy-l4. Add a layer4 block in a JSON config or via the xcaddy build with the plugin. Refer to the caddy-l4 documentation for syntax.
Why HostSNI("*")
During the TLS handshake, the agent sends an SNI equal to tls_server in agent.conf — this defaults to watchflare (the certificate CN generated by the Hub). A rule like HostSNI("watchflare") would need to match this value exactly.
Using HostSNI("*") avoids mismatches if the certificate CN changes (e.g. when switching to TLS_MODE=custom with a different CN). It is safe because port 50051 is dedicated to Watchflare gRPC — security is enforced by TLS 1.3 and per-request HMAC authentication, not by SNI filtering.
After setup
Once HTTPS is working, update your .env to ensure session cookies are correctly marked Secure:
COOKIE_DOMAIN=watchflare.example.com See HTTPS setup for details on cookie security and how to verify it works.