Reverse Proxy & HTTPS
May 17, 2026 · View on GitHub
LightNVR's HTTP server does not terminate TLS itself. For HTTPS — and for any deployment that exposes the dashboard to a network you don't fully trust — run a reverse proxy (nginx, Caddy, Traefik, HAProxy, an ingress controller, …) in front and let it handle certificates.
This doc covers the two things that aren't obvious if you've never put a reverse proxy in front of an NVR:
- You have to forward two upstream services, not one — lightNVR on port 8080 and go2rtc on port 1984.
- WebSocket upgrade headers are mandatory for the
/go2rtc/*path or MSE live view and WebRTC SDP exchange will fail silently.
Upstream services
| Path on the reverse proxy | Upstream | Why |
|---|---|---|
/ (everything except /go2rtc/) | http://127.0.0.1:8080 | lightNVR's HTTP server. Serves the dashboard, REST API (/api/*), HLS segments (/hls/*), and lightNVR's internal go2rtc HTTP-only proxy at /go2rtc/api/streams, /go2rtc/api/stream.m3u8, /go2rtc/api/hls/*, /go2rtc/api/frame.jpeg. |
/go2rtc/* | http://127.0.0.1:1984 | go2rtc's own HTTP/WS server. Required because lightNVR's built-in /go2rtc/* proxy is HTTP-only (uses libcurl) and cannot handle WebSocket upgrades — and MSE live view and WebRTC SDP exchange both need WebSocket. |
Why two upstreams instead of one? lightNVR proxies a curated subset of go2rtc endpoints internally (HLS segments and snapshots) because those benefit from lightNVR's concurrency limiter and auth. Everything else —
/go2rtc/api/wsfor MSE,/go2rtc/api/webrtcSDP, and the go2rtc admin UI — needs to reach go2rtc directly. When you're proxying HTTPS, the cleanest fix is to route the whole/go2rtc/*prefix to go2rtc and let the frontend (which detectshttps:and switches routing automatically) pick the right path. Seeweb/js/utils/settings-utils.js#getGo2rtcBaseUrl/#getGo2rtcWebSocketUrlfor the client-side logic.
The trust setting
LightNVR refuses to honor X-Forwarded-For / X-Real-IP headers by default — otherwise any client could spoof a source IP for the audit log, login allow-list, and rate limiter. Tell lightNVR which proxies it can trust by setting trusted_proxy_cidrs in /etc/lightnvr/lightnvr.ini:
[web]
trusted_proxy_cidrs = 127.0.0.1/32,::1/128
The value is a comma- or newline-separated list of IPv4/IPv6 CIDRs that may legitimately set the forwarded headers. When the request arrives from one of those addresses, lightNVR uses the leftmost untrusted IP in X-Forwarded-For (or X-Real-IP if present) as the client address. If your reverse proxy runs on the same host, 127.0.0.1/32,::1/128 is correct; if it runs in a separate container on a Docker bridge, use that bridge's CIDR (e.g. 172.18.0.0/16); for Kubernetes, the pod or node CIDR.
Example: nginx
This config — adapted from a working deployment by @dpw13 — terminates HTTPS, forwards / to lightNVR, and forwards /go2rtc/* to go2rtc. WebSocket upgrades are wired up on both locations because lightNVR uses WS for some live-view paths and go2rtc uses WS for MSE + WebRTC.
# Redirect HTTP → HTTPS
server {
listen 80;
listen [::]:80;
server_name nvr.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name nvr.example.com;
ssl_certificate /etc/ssl/certs/nvr.crt;
ssl_certificate_key /etc/ssl/private/nvr.key;
ssl_protocols TLSv1.2 TLSv1.3;
# HSTS — opt in once you're sure the cert chain is good.
# add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# WebSocket upgrade headers travel through the upstream connection.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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-lived streams (MSE WebSockets, HLS pulls) must not time out at
# nginx's default 60s read timeout.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Everything except /go2rtc/* → lightNVR
location / {
proxy_pass http://127.0.0.1:8080;
}
# /go2rtc/* → go2rtc (bypasses lightNVR's HTTP-only internal proxy
# so MSE WebSockets and WebRTC SDP can be upgraded)
location /go2rtc/ {
proxy_pass http://127.0.0.1:1984;
}
}
If your nginx fronts containers, the upstreams will be container names or service IPs instead of 127.0.0.1.
Example: Caddy
Caddy handles WebSocket upgrades, HSTS, and Let's Encrypt certificates without any extra knobs.
nvr.example.com {
encode zstd gzip
# /go2rtc/* must come BEFORE the catch-all
handle /go2rtc/* {
reverse_proxy 127.0.0.1:1984 {
transport http {
read_timeout 1h
write_timeout 1h
}
}
}
handle {
reverse_proxy 127.0.0.1:8080
}
}
Example: Traefik
For a Docker/Kubernetes deployment that's already using Traefik, use two routers — one for /go2rtc/* and one for everything else — so the path-based split routes WebSocket traffic to the right service. Compose example:
services:
lightnvr:
image: ghcr.io/opensensor/lightnvr:latest
labels:
# Catch-all → lightNVR's HTTP port
traefik.enable: "true"
traefik.http.routers.lightnvr.rule: "Host(`nvr.example.com`)"
traefik.http.routers.lightnvr.tls: "true"
traefik.http.routers.lightnvr.tls.certresolver: "letsencrypt"
traefik.http.services.lightnvr.loadbalancer.server.port: "8080"
# /go2rtc/* → go2rtc's port on the same container
# Higher priority wins when both rules match the host.
traefik.http.routers.go2rtc.rule: "Host(`nvr.example.com`) && PathPrefix(`/go2rtc`)"
traefik.http.routers.go2rtc.priority: "100"
traefik.http.routers.go2rtc.tls: "true"
traefik.http.routers.go2rtc.tls.certresolver: "letsencrypt"
traefik.http.routers.go2rtc.service: "go2rtc"
traefik.http.services.go2rtc.loadbalancer.server.port: "1984"
Traefik upgrades WebSockets by default; no extra middleware is needed.
Verification
After starting the proxy, walk through this checklist with the browser dev tools open:
- Dashboard loads over
https://— confirms the/route reaches lightNVR. - Live view shows video (any backend — WebRTC, HLS, or MSE) — confirms at least the matching subset of go2rtc routing works.
- Open the Network tab and reload Live View — there should be no requests to
:1984or:8555. Every request should hit your reverse proxy hostname overhttps:/wss:. A request to:1984over plainhttp://is a mixed-content bug and the browser will refuse it. - Check
wss://yourhost/go2rtc/api/ws?src=<stream>— this is the MSE WebSocket. Status should be101 Switching Protocols. If it's404or hangs, your/go2rtc/*route is missing or doesn't upgrade. - Switch a camera to WebRTC in the live view — the WebRTC SDP exchange is over POST to
/go2rtc/api/webrtc, so this fails the same way an MSE WebSocket would if/go2rtc/*isn't routed. - Open Settings → Users and check the audit log — recent logins should show your laptop's actual IP, not
127.0.0.1. If they show127.0.0.1,trusted_proxy_cidrsisn't set or doesn't cover the proxy's source address.
Common pitfalls
- Forgot
Upgrade/Connectionheaders. MSE live view fails to start; the WebSocket request returns 200 (or HTML) instead of 101. In nginx, this isproxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";plusproxy_http_version 1.1;in the appropriate scope. - Routed only
/to lightNVR and skipped/go2rtc/*. HLS and snapshots work (those go through lightNVR's internal HTTP-only proxy), but anything live (MSE/WebRTC) breaks the moment WebSocket is needed. - Mixed
http:/https:content. The frontend detectswindow.location.protocoland switches its go2rtc URLs tohttps://yourhost/go2rtc. If you serve the dashboard overhttps:but the browser ends up requestinghttp://yourhost:1984/...directly, your reverse proxy isn't intercepting/go2rtc/*— re-check the location/router ordering. trusted_proxy_cidrsleft empty. Audit log and rate limiter see every request as coming from the proxy (127.0.0.1or the container bridge IP). Login allow-lists effectively disable themselves because everyone looks local.proxy_read_timeoutat nginx's 60s default. A WebSocket sitting idle (e.g., a paused live view, or a sub-stream with low frame rate) gets torn down after a minute. Bump it to something like an hour for the proxy paths that carry streaming traffic.- Reverse proxy on a separate host. The proxy's source IP, not
127.0.0.1, needs to be intrusted_proxy_cidrs. In Docker that's the bridge gateway (often172.17.0.1/32); in Kubernetes that's the pod or node CIDR.
Related
web/js/utils/settings-utils.js— frontend logic that decides between direct go2rtc port and reverse-proxy paths based onwindow.location.protocol.src/web/httpd_utils.c— server-sideX-Forwarded-For/X-Real-IPhandling, gated bytrusted_proxy_cidrs.- Configuration Guide — full
[web]section reference, includingtrusted_proxy_cidrs. - Docker Deployment — when running lightNVR + go2rtc as containers behind a host-level proxy.
- go2rtc Config Override — if you need to change go2rtc's listen address or base path, edit
override.yamlrather than hand-editing the generatedgo2rtc.yaml.