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:

  1. You have to forward two upstream services, not one — lightNVR on port 8080 and go2rtc on port 1984.
  2. 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 proxyUpstreamWhy
/ (everything except /go2rtc/)http://127.0.0.1:8080lightNVR'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:1984go2rtc'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/ws for MSE, /go2rtc/api/webrtc SDP, 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 detects https: and switches routing automatically) pick the right path. See web/js/utils/settings-utils.js#getGo2rtcBaseUrl / #getGo2rtcWebSocketUrl for 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:

  1. Dashboard loads over https:// — confirms the / route reaches lightNVR.
  2. Live view shows video (any backend — WebRTC, HLS, or MSE) — confirms at least the matching subset of go2rtc routing works.
  3. Open the Network tab and reload Live View — there should be no requests to :1984 or :8555. Every request should hit your reverse proxy hostname over https: / wss:. A request to :1984 over plain http:// is a mixed-content bug and the browser will refuse it.
  4. Check wss://yourhost/go2rtc/api/ws?src=<stream> — this is the MSE WebSocket. Status should be 101 Switching Protocols. If it's 404 or hangs, your /go2rtc/* route is missing or doesn't upgrade.
  5. 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.
  6. Open Settings → Users and check the audit log — recent logins should show your laptop's actual IP, not 127.0.0.1. If they show 127.0.0.1, trusted_proxy_cidrs isn't set or doesn't cover the proxy's source address.

Common pitfalls

  • Forgot Upgrade / Connection headers. MSE live view fails to start; the WebSocket request returns 200 (or HTML) instead of 101. In nginx, this is proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; plus proxy_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 detects window.location.protocol and switches its go2rtc URLs to https://yourhost/go2rtc. If you serve the dashboard over https: but the browser ends up requesting http://yourhost:1984/... directly, your reverse proxy isn't intercepting /go2rtc/* — re-check the location/router ordering.
  • trusted_proxy_cidrs left empty. Audit log and rate limiter see every request as coming from the proxy (127.0.0.1 or the container bridge IP). Login allow-lists effectively disable themselves because everyone looks local.
  • proxy_read_timeout at 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 in trusted_proxy_cidrs. In Docker that's the bridge gateway (often 172.17.0.1/32); in Kubernetes that's the pod or node CIDR.
  • web/js/utils/settings-utils.js — frontend logic that decides between direct go2rtc port and reverse-proxy paths based on window.location.protocol.
  • src/web/httpd_utils.c — server-side X-Forwarded-For / X-Real-IP handling, gated by trusted_proxy_cidrs.
  • Configuration Guide — full [web] section reference, including trusted_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.yaml rather than hand-editing the generated go2rtc.yaml.