Reverse proxy so end-user IP addresses never reach Userpilot's servers directly.
Without this proxy, the SDK connects straight to:
js.userpilot.io— script CDNanalytex.userpilot.io— realtime events websocket (/v1/events/websocket)
nginx.conf proxies two paths:
/sdk/→https://js.userpilot.io/sdk//userpilot/websocket→https://analytex.userpilot.io/v1/events/websocket(with websocket upgrade handling)
Both locations:
- Strip
X-Forwarded-For/X-Real-IP/X-Client-IPbefore forwarding, so the end user's real IP is never leaked - Use
set $varfor the upstream hostname (not hardcoded inproxy_pass), withresolver 127.0.0.11 valid=30s(Docker's DNS) — community nginx only resolves a hardcodedproxy_passhostname once at startup, and Userpilot's backend IPs rotate
docker compose up -dOpen http://localhost:8080/test.html, then check DevTools → Network:
- SDK script should load via
/sdk/latest.js - Neither request should hit a
userpilot.iodomain directly
The websocket needs TLS to work — see below.
The SDK always opens the events websocket as wss://, regardless of the page's own protocol. To test it locally:
brew install mkcert
mkcert -install # adds a local CA to your system/browser trust store
mkdir -p certs
mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1docker-compose.ymlmounts./certsand exposes port8443nginx.conflistens on443 sslusing those certstest.html'sendpointpoints atlocalhost:8443/userpilot
Works whether the page itself is loaded via http://localhost:8080/test.html or https://localhost:8443/test.html — a page can always open a secure wss:// connection elsewhere, only the reverse is blocked. Confirm in DevTools → Network: SDK script loads, and the websocket opens with 101 Switching Protocols.
To undo: mkcert -uninstall. Keep certs/ out of version control (already gitignored) — it holds a private key.
Watch live traffic:
docker compose logs -f proxyEvery request logs the exact upstream URL nginx forwarded to, the response status, and IP-stripping status, e.g.:
"GET /sdk/latest.js HTTP/1.1" -> "https://js.userpilot.io/sdk/latest.js" status=200 upstream_status=200
client_sent[XFF="-" X-Real-IP="-" X-Client-IP="-"] ip_stripping=stripped (...)
To see the stripping happen with a spoofed IP:
curl -H "X-Forwarded-For: 1.2.3.4" http://localhost:8080/sdk/latest.jsThen check the log — the spoofed IP shows up in client_sent[...], but ip_stripping=stripped confirms it never reached the upstream request.
Note:
curl's own output is just the response body (the SDK JS file) — the log line only appears indocker compose logs, not incurl's terminal.
Symptom: a proxied resource's path repeats itself, e.g.:
/services/some-integration/proxy/services/some-integration/proxy/uploads/<hash>.png
Cause: this happens when the local proxy prefix doesn't match the real upstream path (unlike this repo's /sdk/, which happens to mirror js.userpilot.io's own /sdk/ path). With a variable upstream host, proxy_pass does not auto-strip the matched location prefix — so $request_uri (prefix included) gets forwarded as-is. If the upstream then returns a link built from that prefixed path, and something requests it through the same proxy again, the prefix doubles up.
Fix: strip the prefix explicitly with a regex capture instead of forwarding $request_uri verbatim:
location ~ ^/services/some-integration/proxy/(.*)$ {
set $upstream real-upstream-host.com;
proxy_pass https://$upstream/$1$is_args$args;
}Confirmed locally: the unstripped pattern 404s against an upstream whose real path doesn't include the local prefix; the regex-strip pattern above resolves correctly.
- Needs a real cert (e.g. Let's Encrypt) for the public domain — locally this uses an mkcert cert trusted only on this machine
- Can't run on
localhostin production — needs a real server with a public domain (e.g.proxy.yourcompany.com) reachable by all end users - The
127.0.0.11resolver only works inside Docker — swap for a real DNS resolver (e.g.8.8.8.8) if deploying outside Docker