Viewer guide

HTTPS and a reverse proxy

The Viewer speaks plain HTTP. Put a proxy that terminates TLS in front of it, and set the two values that keep cookies and rate limiting correct behind it.

This assumes the container from Deploy the Viewer is up on port 3100.

The Viewer speaks plain HTTP and terminates nothing. In production you put a TLS-terminating proxy in front of it. There is one non-obvious rule.

Warning Set VIEWER_PUBLIC_URL to the https URL. The Viewer does not read X-Forwarded-Proto for this: it decides whether to mark the session cookie Secure purely by checking whether VIEWER_PUBLIC_URL starts with https://. Leave it on an http:// value behind an HTTPS proxy and your session cookies ship without Secure, meaning a browser will send them over plain HTTP too.

Also set VIEWER_TRUST_PROXY (for one proxy: 1) whenever a reverse proxy terminates TLS in front of the Viewer. It feeds Express's trust proxy, which is what lets the built-in rate limiter see real client addresses instead of the proxy's. The literal value true is refused at boot because it would trust a client-supplied header.

The session cookie is HttpOnly and SameSite=Lax regardless; Secure is the only part that depends on this.

An nginx server block:

server {
  listen 443 ssl;
  server_name proto.internal.example.com;
 
  ssl_certificate     /etc/ssl/certs/proto.crt;
  ssl_certificate_key /etc/ssl/private/proto.key;
 
  # Tell browsers never to try this host over plain http again. The Viewer
  # cannot set this itself: it speaks http and terminates no TLS, so it does
  # not know the connection was secure. Without it, the very first request a
  # browser makes to a bare hostname is http, and it is interceptable even
  # though every later one is not.
  add_header Strict-Transport-Security "max-age=63072000" always;
 
  location / {
    proxy_pass http://127.0.0.1:3100;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
 
    # Required by VIEWER_TRUST_PROXY. See the warning below.
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 
    # Build logs and comment updates are server-sent events.
    proxy_buffering off;
    proxy_read_timeout 15m;
  }
}

always is not optional on that add_header line. Without it nginx omits the header on error responses, which is most of what an attacker can provoke. Note also that nginx's add_header does not merge: a location block that adds any header of its own drops every header inherited from the server block, so if you add per-location headers later you must repeat this line there too. Warning proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; is not optional once you set VIEWER_TRUST_PROXY. X-Forwarded-For is the header trust proxy reads, and nginx does not set it on its own.

Leave it out and you get worse than the shared-bucket problem VIEWER_TRUST_PROXY exists to fix. nginx passes a client's own headers through to the upstream, so a caller can send their own X-Forwarded-For, and the Viewer, told to trust one proxy, will believe it. Rotating that header then gives a fresh rate-limit bucket per request on the sign-in and comment-write lanes.

$proxy_add_x_forwarded_for appends the real peer address to whatever the client sent, which is what makes the value trustworthy at the hop count you configured.

The last two lines matter. Build logs and comment updates stream over server-sent events, so a buffering proxy holds the log until the build finishes and shows the user a frozen panel. And a single build step is allowed up to ten minutes, so a default 60-second read timeout will cut the stream mid-build on any real project.

If you use VIEWER_SERVE_DOMAIN to serve each prototype on its own subdomain, the proxy needs a wildcard certificate and a wildcard server_name for that domain, and it must pass the original Host header through: the Viewer routes on it. See serving.