5 minutes
Caddy as a Reverse Proxy for UniFi
I have a Caddy reverse proxy sat in front of my Unifi devices (Dream Machine Pro, NAS etc.) that validates their respective certificates and uses Lets Encrypt so clients can access a publicly signed certificate. Recently, after a Unifi update, it stopped working. I had to do quite a bit of debugging to get it working again.
Why Not Just Trust the Self-Signed Cert?
The UDM Pro ships with a self-signed certificate. The lazy option is to add it to your OS trust store (import it into macOS Keychain, mark it as trusted, done). That works, but it has a few problems I didn’t want to live with:
- You have to do it on every device that needs access. Phone, laptop, work machine, each one needs the cert imported manually.
- When UniFi rotates or regenerates the cert (which it does), you have to re-import it everywhere.
- It doesn’t generalise. Every piece of self-signed kit on your network (Proxmox, NAS, etc.) needs its own cert imported separately.
- Browsers are increasingly hostile to self-signed certs even after import, and some things just don’t work at all (WebSocket connections in particular can behave oddly).
The better option is to put a proper reverse proxy in front of everything, get a real Let’s Encrypt cert for a subdomain you control, and let the proxy handle the trust relationship with the backend. Clients only ever see the trusted cert; the self-signed cert stays internal.
Plus, I can add additional security controls on the proxy, and allow only the proxy to access the management services (with an obvious backup break-glass option). For instace, using Caddy WAF.
The Setup
I run Caddy as a reverse proxy in my Kubernetes cluster. It handles Let’s Encrypt certs automatically via DNS-01 challenge and proxies traffic to the actual hardware on my network.
The traffic flow is:
Browser -> Caddy (TLS termination, Let's Encrypt cert)
-> UniFi / Proxmox / NAS / etc (HTTPS with self-signed cert)
Caddy re-establishes HTTPS to each backend using its self-signed cert. To do that without rejecting the connection, you export the backend’s cert and tell Caddy to trust it explicitly via tls_trust_pool.
The result: https://unifi.example.com with a real, trusted cert, proxied cleanly to https://192.168.1.1:443. No cert imports, no browser warnings, works on every device automatically.
Caddy does the cert validation, so if the backend cert isn’t right it’ll reject the connection, meaning we can trust we’re talking to the actual Unifi box.
The Caddyfile
Here’s the working config for the UniFi block. The rest of the post explains the less obvious parts.
:80 {
handle /health {
respond "OK" 200
}
@network header X-Forwarded-Host unifi.example.com
handle @network {
@wsupgrade header Upgrade websocket
handle @wsupgrade {
reverse_proxy https://192.168.1.1:443 {
flush_interval -1
header_up Origin "https://192.168.1.1"
transport http {
versions 1.1
tls_trust_pool file {
pem_file /certs/unifi-local.pem
}
tls_server_name unifi.local
}
}
}
handle {
reverse_proxy https://192.168.1.1:443 {
flush_interval -1
transport http {
versions 1.1
tls_trust_pool file {
pem_file /certs/unifi-local.pem
}
tls_server_name unifi.local
}
}
}
}
}
Caddy is listening on port 80 here because my ingress controller handles TLS termination upstream, with a mTLS service mesh. The X-Forwarded-Host header is how Caddy knows which backend to route to (the ingress sets it, Caddy matches on it).
The tls_trust_pool and tls_server_name tell Caddy how to connect to UniFi’s HTTPS backend. You export the UDM’s self-signed cert, mount it into the Caddy pod, and point the trust pool at it. tls_server_name sets the SNI so the UDM knows which cert to present.
The WebSocket handling is split into a separate block (more on that below).
What Broke After Upgrading Caddy
This had been working fine for well over a year. After upgrading Caddy to 2.10+, the UniFi UI went to a blank screen on load with WebSocket errors in the browser console. The page itself never rendered, everything UniFi’s frontend needs to bootstrap comes over WebSocket.
Two things were broken simultaneously.
Caddy 2.10 Defaulted to HTTP/2 for TLS Backends
From Caddy 2.10 onwards, when proxying to a TLS backend, Caddy defaults to HTTP/2. WebSocket uses Connection: Upgrade and Upgrade: websocket (hop-by-hop headers that don’t exist in HTTP/2). HTTP/2 has its own extension mechanism for WebSocket-over-HTTP/2, and the UDM doesn’t support it.
Fix: force HTTP/1.1 in the transport block.
transport http {
versions 1.1
...
}
One line. Took longer than it should have to find.
UniFi 10.4.57 Started Enforcing Origin Header Validation
Around the same time, UniFi Network Application 10.4.57 appears to have introduced strict Origin header validation on WebSocket endpoints. When the browser connects to wss://unifi.example.com/api/ws/webrtc/local, it sends Origin: https://unifi.example.com. That gets proxied through to the UDM, which now checks that the Origin matches its own hostname and rejects anything external with a 500.
The fix is to rewrite the Origin header for WebSocket upgrade requests before forwarding them:
@wsupgrade header Upgrade websocket
handle @wsupgrade {
reverse_proxy https://192.168.1.1:443 {
header_up Origin "https://192.168.1.1"
...
}
}
The @wsupgrade matcher catches any request with Upgrade: websocket regardless of path. Both /api/ws/webrtc/local and /api/ws/system were failing, so matching on path alone wasn’t sufficient.
The regular handle block (non-WebSocket requests) doesn’t get the Origin rewrite, which matters because /api/system and other REST endpoints need the original headers left alone.
One trap: don’t add header_up Connection {>Connection} and header_up Upgrade {>Upgrade} to the WebSocket block. It seems like you should explicitly forward those headers, but Caddy handles them automatically for WebSocket connections. Setting them manually causes UniFi’s nginx to return 400 “Invalid Upgrade header” because something ends up duplicated. Let Caddy manage them.
The Debugging
Having two things break at once made the symptoms confusing. The HTTP/2 mismatch and the Origin validation both manifest as WebSocket failures, but produce different HTTP status codes (connection failure vs 500). Fixing the HTTP/2 issue first unmasked the Origin problem.
Result
Trusted Let’s Encrypt cert, no per-device imports, WebSocket working again. The same Caddy pattern applies to the other backends (Proxmox, NAS, Home Assistant) with versions 1.1 in the transport block and flush_interval -1 for streaming, minus the WebSocket Origin rewrite which only UniFi seems to enforce.
If your UniFi UI went blank after a recent update and you’re running a reverse proxy, the versions 1.1 transport directive and an Origin rewrite for WebSocket are the two things to check.