Skip to content

Reverse proxy

Parley sends Content-Security-Policy, X-Content-Type-Options, X-Frame-Options and Referrer-Policy itself — see hardening checklist step 3. The five headers it does not send, and that belong here instead, are Strict-Transport-Security, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy. Both blocks below set all five. Do not add one Parley already sends: a proxy add_header or header block wins over the app’s value, so duplicating one quietly changes the policy instead of confirming it.

Cross-Origin-Embedder-Policy: credentialless rather than require-corp is deliberate — see the plugin-iframe note under hardening checklist step 3 before changing it.

Caddy handles WebSockets and TLS automatically; add the five headers with header. The handle /metrics* block keeps /metrics off the public hostname if you later turn METRICS_ENABLED on — scrape the process from inside the network instead:

parley.example.com {
handle /metrics* {
respond 404
}
reverse_proxy 127.0.0.1:8080
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Embedder-Policy "credentialless"
Cross-Origin-Resource-Policy "same-origin"
}
}

Verify it landed:

Terminal window
curl -sI https://parley.example.com | grep -Ei 'strict-transport|permissions-policy|cross-origin'

Needs the upgrade headers and a read timeout longer than a quiet standup speaker. Parley pings every 25s, so 75s is safe. This is a full server context because a nested add_header replaces every header inherited from an outer context rather than adding to it — put these in a location block instead and they silently drop whatever was set on server. Each uses always, or nginx omits it on any response that is not a plain 2xx/3xx — exactly the responses you most want a security header on. location /metrics returns 404 so the unauthenticated exposition never reaches the public hostname:

server {
listen 443 ssl;
server_name parley.example.com;
ssl_certificate /etc/letsencrypt/live/parley.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/parley.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
location /metrics {
return 404;
}
location / {
proxy_pass http://127.0.0.1:8080;
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-Forwarded-For $remote_addr;
proxy_read_timeout 75s;
proxy_send_timeout 75s;
}
}

Verify it landed:

Terminal window
curl -sI https://parley.example.com | grep -Ei 'strict-transport|permissions-policy|cross-origin'
Terminal window
BASE_URL=https://parley.example.com
TRUST_PROXY_HEADERS=true
TRUSTED_PROXY_CIDRS=127.0.0.1/32

BASE_URL must match the address in the browser’s bar exactly — scheme, host and port — or the WebSocket origin check rejects every connection.

TRUST_PROXY_HEADERS=true lets the room-code and identity-creation throttles count real clients instead of seeing every request as coming from the proxy. It requires TRUSTED_PROXY_CIDRS: use 127.0.0.1/32 only for a same-host proxy, or the actual Ingress/load-balancer networks for another topology. List every trusted hop and never a client-reachable network. Parley ignores forwarded headers from an untrusted immediate peer. See Configuration.

Two hops: nginx in front of an ingress controller

Section titled “Two hops: nginx in front of an ingress controller”

A common self-hosted shape is TLS terminated by nginx on a separate host, which forwards to a Kubernetes ingress controller, which forwards to Parley. Every hop in that chain has to cooperate or the client address does not survive it.

The outermost proxy appends. The nginx block above uses X-Forwarded-For $remote_addr, which sets the header. When nginx is the first hop that is the same thing, but the appending form is what you want in a chain:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

The middle hop must be told to trust the outer one, or it throws the chain away. This is the step that is easy to read as optional hardening and is not. Traefik does not forward an incoming X-Forwarded-For unless the peer that sent it is listed in forwardedHeaders.trustedIPs — an untrusted peer’s forwarded headers are dropped and replaced. So omitting it does not merely widen trust; it truncates the chain, Parley sees only the controller, and the room-code throttle collapses to a single bucket for the whole internet. Set it:

ports:
websecure:
forwardedHeaders:
trustedIPs:
- 203.0.113.10/32 # the nginx host, as Traefik actually sees it

ingress-nginx is the same idea under a different name: proxy-real-ip-cidr in its ConfigMap.

Find out what address that hop really is, empirically. Do not assume it is the nginx host’s public address. With k3s servicelb and the default externalTrafficPolicy: Cluster, traffic entering a node is SNAT’d before it reaches the controller, so Traefik may well see a node address instead. Which one it is depends on your load balancer, your service configuration and your CNI, and getting it wrong fails in the quiet direction — the trust list simply never matches. Read it off a request rather than deriving it:

Terminal window
kubectl --namespace kube-system logs deploy/traefik | tail

with the access log on, or set LOG_LEVEL=debug on Parley and read the peer and forwarded_for fields it logs at the trust decision. See Observability.

Then set Parley’s own list to the controller’s network, which on Kubernetes means the pod CIDR — and read the trap that comes with it before you do. Parley walks the chain right-to-left past every trusted hop, so with all three configured the address it resolves is the real client’s.

TLS and correct proxy addresses are necessary but are not access control. AUTH_MODE=open is trusted-network-only. Before exposing Parley publicly, require a space passcode or put an external SSO/authentication proxy in front, and configure request, connection, and bandwidth limits at this layer.

/version needs no auth to answer and is handy for a health check, but it also tells an internet scanner exactly which CVEs apply to you — restrict it to your monitoring network at the proxy, for example location = /version { allow 10.0.0.0/8; deny all; proxy_pass http://127.0.0.1:8080; } in nginx.

/metrics is the same shape, and louder: with METRICS_ENABLED=true it is an unauthenticated Prometheus exposition on the app port, and the chart’s Ingress routes path: / Prefix, so it is published unless something in front denies it. The Caddy handle /metrics* and nginx location /metrics blocks above return 404 for that path. On ingress-nginx, a server-snippet does the same job:

nginx.ingress.kubernetes.io/server-snippet: |
location /metrics { return 404; }

Scrape the Service from inside the cluster instead. See Observability.