Reverse proxy configuration
Configure Traefik, Nginx, or Caddy in front of the Watchflare Hub. Covers HTTP reverse proxy for the dashboard and TCP passthrough for the gRPC port.
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.
Rate limiting
The Hub’s dashboard is a single-page app: a cold page load fetches dozens of static assets from /_app/immutable/* in parallel, more so on heavier pages. If your proxy enforces a request rate limit, the burst must be high enough to absorb a full page load. A burst that is too low returns 429 for some of those assets, which the browser reports as NS_ERROR_CORRUPTED_CONTENT and a failed page load (the dashboard then shows an “Internal Error” page).
Warning
Rate limiting immutable static assets by request count rarely adds value and easily breaks the dashboard. Use a generous burst (a few hundred), or scope the rate limit to API paths only.
The values shown below are only examples: size the burst to your own page weight and client count, there is no required number.
For Traefik, this is the burst value on the rateLimit middleware:
http:
middlewares:
watchflare-ratelimit:
rateLimit:
average: 300 # example, tune to your setup
burst: 300 # example, tune to your setup For Nginx, use limit_req with a burst large enough for a full page load (add nodelay so the burst is served immediately, not queued):
# http {} context
limit_req_zone $binary_remote_addr zone=watchflare:10m rate=300r/s; # example
# inside the location block
location / {
limit_req zone=watchflare burst=300 nodelay; # example, tune to your setup
proxy_pass http://HUB_IP:8080;
# ... your other proxy_set_header / SSE settings
} Caddy has no built-in rate limiting, so there is nothing to tune by default. If you add rate limiting through a plugin, size it generously or scope it to API paths only, following the same principle.
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.