Reverse proxy and HTTPS
Put Caddy, nginx or Traefik in front of Pinwren for TLS, compression, the forwarded headers and the body sizes it needs.
In production, run Pinwren behind a TLS-terminating reverse proxy. Pinwren listens on plain HTTP (port 3000
in the image), and the compose file publishes it on 127.0.0.1:3000 for exactly this: the proxy on the same
host reaches it, the internet does not.
What the proxy must do
| Requirement | Why |
|---|---|
| Terminate TLS | Widgets on HTTPS sites can only call an HTTPS server, and sessions should never travel in clear text. |
Append to X-Forwarded-For, set -Proto/-Host | With PINWREN_TRUST_PROXY set to the number of proxies, Pinwren uses them for the client IP (rate limits, sign-in limiter) and the request's scheme and host (HTTPS enforcement). See Client addresses. |
| Allow request bodies of 48 MB | A bug report is one JSON request: a screenshot (up to 8 MB, about 11 MB as a base64 data URL), up to three 5 MB attachments (about 7 MB each once encoded) and a session replay of up to 10 MB (about 13.4 MB encoded). Pinwren itself accepts bodies up to 48 MB and checks every per-file limit on its own. |
Don't buffer /api/stream | The dashboard's live updates are a long-lived Server-Sent Events response. Keep it open for at least 16 minutes: Pinwren ends each stream after 15 minutes and the browser reconnects. |
| Compress the widget with brotli (gzip as fallback) | /widget.js and its chunks are what your customers' visitors download. Let the proxy compress them (and cache the compressed copies). |
| Keep Pinwren's cache headers | /widget.js is served with Cache-Control: public, max-age=300 (its name never changes); its chunks /widget-<name>.<10-hex-hash>.js with public, max-age=31536000, immutable (their name changes with their content). Both carry Access-Control-Allow-Origin: * because customer sites load them cross-origin. |
Pinwren's own server gzips responses when the client asks for gzip. For brotli, have the proxy request
uncompressed responses from Pinwren (drop the Accept-Encoding header on the way upstream, as in the
examples below) and compress them itself. The real-time stream is sent with Cache-Control: no-cache, no-transform and X-Accel-Buffering: no, so compressing proxies leave it alone.
Leave security headers to Pinwren. Dashboard pages are sent with their own Content-Security-Policy,
X-Frame-Options: DENY, Referrer-Policy, Cross-Origin-Opener-Policy and Permissions-Policy; the widget's
files and /w are exempt so customer sites can load them. Don't add a second CSP at the proxy — browsers
enforce both, and a stricter one breaks the dashboard. HSTS comes from PINWREN_ENFORCE_HTTPS
(below).
Client addresses
PINWREN_TRUST_PROXY is a hop count: the number of proxies between the internet and Pinwren whose
X-Forwarded-For entries it believes.
| Setup | PINWREN_TRUST_PROXY |
|---|---|
| Clients reach Pinwren directly (no proxy) | 0 (or false) |
| One proxy — Caddy, nginx or Traefik (the compose default) | 1 (or true) |
| A CDN or load balancer in front of your proxy | 2 |
Each proxy adds the address it received the request from to the right end of X-Forwarded-For. Pinwren
reads that header from the right and skips one entry per trusted hop, so the address it uses is the one your
outermost trusted proxy saw — anything a client wrote into the header itself sits further left and is ignored.
X-Real-IP is never read.
For this to hold, every proxy in the chain must append to the X-Forwarded-For it received, not overwrite
it with a value of its own choosing and not pass a client's header through unchanged. nginx does this with
$proxy_add_x_forwarded_for. Caddy and Traefik append only for requests from addresses they trust, so behind a
CDN list the CDN's ranges as trusted_proxies (Caddy) or forwardedHeaders.trustedIPs (Traefik).
Count the proxies exactly
Too low, and Pinwren sees your proxy's address for every visitor, so all of them share one rate limit. Too high, or set when clients can reach the container directly, and anyone can pick their own address and step around the per-IP limits. With no trusted hop, forwarded headers are ignored and the socket address is used.
Caddy
Caddy gets a certificate automatically, sets the forwarded headers and streams Server-Sent Events without
buffering. Brotli needs a Caddy build with an encoder plugin (for example
xcaddy build --with github.com/ueffel/caddy-brotli); without it, use encode zstd gzip.
feedback.example.com {
# Brotli first; gzip for older clients.
encode br zstd gzip
request_body {
max_size 48MB
}
reverse_proxy 127.0.0.1:3000 {
# Ask Pinwren for uncompressed responses; Caddy compresses them.
header_up -Accept-Encoding
# Server-Sent Events: write each event straight through.
flush_interval -1
transport http {
read_timeout 20m
}
}
}nginx
Brotli needs the ngx_brotli module (packaged as libnginx-mod-brotli
on Debian/Ubuntu, built into the fholzer/nginx-brotli image). Without it, drop the brotli lines and keep
gzip.
upstream pinwren {
server 127.0.0.1:3000;
keepalive 16;
}
server {
listen 443 ssl;
http2 on;
server_name feedback.example.com;
ssl_certificate /etc/letsencrypt/live/feedback.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/feedback.example.com/privkey.pem;
# Screenshots, attachments and the session replay arrive as one JSON body.
client_max_body_size 48m;
brotli on;
brotli_comp_level 6;
brotli_types text/javascript application/javascript application/json text/css image/svg+xml;
gzip on;
gzip_types text/javascript application/javascript application/json text/css image/svg+xml;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Ask Pinwren for uncompressed responses; nginx compresses them.
proxy_set_header Accept-Encoding "";
# Live updates (Server-Sent Events): no buffering, no compression, long reads.
location = /api/stream {
proxy_pass http://pinwren;
proxy_buffering off;
proxy_cache off;
gzip off;
brotli off;
proxy_read_timeout 20m;
}
location / {
proxy_pass http://pinwren;
}
}
server {
listen 80;
server_name feedback.example.com;
return 308 https://$host$request_uri;
}Traefik
If Traefik (v3) already runs on the host, attach the app to its network with labels, in an override file next
to the main compose file. Traefik's compress middleware encodes brotli and gzip and skips
text/event-stream responses, so the real-time stream is not buffered; the buffering middleware sets the
body limit (48 MB = 50331648 bytes).
services:
pinwren:
labels:
- traefik.enable=true
- traefik.http.routers.pinwren.rule=Host(`feedback.example.com`)
- traefik.http.routers.pinwren.entrypoints=websecure
- traefik.http.routers.pinwren.tls.certresolver=letsencrypt
- traefik.http.routers.pinwren.middlewares=pinwren-identity,pinwren-compress,pinwren-body
# An empty value removes the header: Pinwren answers uncompressed and Traefik compresses.
- traefik.http.middlewares.pinwren-identity.headers.customrequestheaders.Accept-Encoding=
- traefik.http.middlewares.pinwren-compress.compress.encodings=br,gzip
- traefik.http.middlewares.pinwren-body.buffering.maxRequestBodyBytes=50331648
- traefik.http.services.pinwren.loadbalancer.server.port=3000
networks: [default, traefik]
networks:
traefik:
external: trueTraefik sets the forwarded headers (and appends to X-Forwarded-For). The entrypoints and certresolver names are whatever your Traefik
configuration defines. For streams longer than Traefik's default timeouts, set
--entrypoints.websecure.transport.respondingTimeouts.readTimeout=20m (and idleTimeout) on the Traefik
command line.
Enforce HTTPS
Once the proxy serves HTTPS, set PINWREN_ENFORCE_HTTPS=true (with PINWREN_TRUST_PROXY at least 1, so the
forwarded scheme counts):
- plain-HTTP
GETandHEADrequests are redirected tohttps://with a308; - any other plain-HTTP request is refused with
403 HTTPS_REQUIRED, so a form post never silently loses its body; - HTTPS responses carry
Strict-Transport-Security: max-age=31536000; includeSubDomains; - requests to
localhost,127.0.0.1and::1are exempt, so health checks and local tools keep working.
The rule covers every route — dashboard pages, /api and the widget's /w. It is environment-only on
purpose: a dashboard switch could lock admins out. Settings › Security shows whether it is on.
Check it
curl -s https://feedback.example.com/api/health # {"ok":true,"version":"0.1.0"}
# Brotli and the short cache on the loader:
curl -sI -H 'Accept-Encoding: br' https://feedback.example.com/widget.js | grep -iE 'content-encoding|cache-control'
# The stream stays open and prints a ping every 25 s (needs a session cookie):
curl -N -H "Cookie: $COOKIE" https://feedback.example.com/api/streamThen load your site and open the project's Install tab: it shows Connected once a widget has called
the server through the proxy.
pinwren doctor --url https://feedback.example.com --key <pk_…> --origin https://your-site.example (from
@pinwren/cli) checks the health endpoint, the widget script and a project's configuration
from the outside.