2026 OpenClaw Gateway: Nginx vs Caddy on the Same Dedicated Remote Mac (SSH Headless) — TLS Termination, WebSocket Upgrade, allowedOrigins & trusted-proxy — Reproducible Deployment Matrix + FAQ
When browsers, IDE webviews, and automation scripts talk to OpenClaw Gateway, listening on 127.0.0.1 alone stops scaling: you need HTTPS, predictable WebSocket upgrades, a tight allowedOrigins story, and an explicit trusted-proxy contract for X-Forwarded-*. This article compares Nginx and Caddy as co-hosted reverse proxies on an exclusive remote Mac managed over SSH—TLS paths, minimal configs, a deployment matrix, headless acceptance checks, and FAQ you can paste into a runbook.
1. Why add a reverse proxy in front of Gateway?
Gateway exposes HTTP, SSE, and WebSocket surfaces. SSH port forwards are fine for solo debugging, but shared teams and SaaS callbacks quickly surface the same constraints:
- HTTPS is required to avoid mixed-content blocks for
EventSourceand secure WebSockets. - You need hostname-based routing when multiple instances (dev/stage/prod) share one machine.
- You want consistent
X-Forwarded-For/X-Forwarded-Protoparsing, rate limits, and optional edge ACLs. - On a headless Mac, certificate issuance and renewal must be scriptable—no GUI wizard.
This post assumes the common topology: Nginx or Caddy on loopback-facing ports, Gateway bound to 127.0.0.1:<port> only. Pair the matrix below with
OpenClaw multi-model keys and provider wiring on remote Mac SSH
so outbound model traffic stays separate from inbound browser trust.
2. Decision matrix: Nginx vs Caddy (co-hosted with Gateway)
Both terminate TLS and reverse-proxy to Gateway. The split is mostly operational ergonomics versus existing fleet patterns:
| Dimension | Nginx | Caddy | Guidance |
|---|---|---|---|
| Config style | Directive + map |
Caddyfile blocks | Prefer Caddy for small teams; keep Nginx if the host already runs complex vhosts. |
| Automatic HTTPS | certbot / acme.sh | Built-in ACME | Public hostname + single-node edge: Caddy usually wins time-to-green. |
| WebSocket upgrade | Manual Upgrade/Connection | reverse_proxy defaults |
Nginx misconfigs here are the top cause of flaky WS; Caddy reduces foot-guns. |
| Trusted client IP chain | set_real_ip_from + real_ip_header |
trusted_proxies |
Never trust raw X-Forwarded-For without an explicit hop list. |
| Long-lived streams | proxy_read_timeout tuning |
Transport timeouts | SSE/WebSockets need ≥1h read timeouts and buffering off. |
| macOS install | brew install nginx |
brew install caddy |
Both are first-class on Apple Silicon via Homebrew. |
One-line picks: greenfield public HTTPS on one host → Caddy; existing multi-site Nginx fleet → Nginx; if the edge is Tailscale Funnel or Cloudflare Tunnel, keep this proxy as the inner TLS/Origin layer and let the tunnel handle upstream identity.
For why bare-metal Mac mini class hardware still beats many "cloud Mac" slices under sustained gateway load, see Real Mac mini vs virtual cloud Mac: 2026 performance and stability comparison.
3. TLS termination paths
3.1 mkcert / local CA
Best for ssh -L workflows and internal hostnames. Import the local root once per operator laptop; renewals are scripted.
3.2 Tailscale TLS (*.ts.net)
Great for tailnet-only teams: no public 80/443 requirement, certificates rotate automatically, but migration across tailnets is deliberate work.
3.3 Let's Encrypt (public DNS)
Requires HTTP-01 reachability on :80 or DNS-01 automation. Caddy wires ACME with fewer moving parts; Nginx typically delegates to certbot hooks plus reload.
3.4 Minimal Caddyfile (example)
# Example only — replace host, port, and log paths.
gw.example.com {
encode gzip
log {
output file /opt/homebrew/var/log/caddy/gw.access.log
format json
}
reverse_proxy 127.0.0.1:18789 {
trusted_proxies 127.0.0.1/32 ::1/128
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
3.5 Minimal Nginx fragment (example)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name gw.example.com;
ssl_certificate /etc/letsencrypt/live/gw.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gw.example.com/privkey.pem;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location / {
proxy_pass http://127.0.0.1:18789;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
Footgun: Nginx without the map + Upgrade headers yields WebSockets that appear to connect then die immediately.
4. WebSocket upgrades and streaming timeouts
| Concern | Nginx | Caddy | Suggested value |
|---|---|---|---|
| Read timeout | proxy_read_timeout |
transport read_timeout |
≥ 1h for long tool runs |
| Write timeout | proxy_send_timeout |
transport write_timeout |
Match read timeout |
| SSE buffering | proxy_buffering off |
flush_interval -1 |
Must disable buffering or first bytes stall |
Acceptance: use curl -N against an SSE endpoint and confirm bytes arrive within seconds; exercise a WebSocket with an idle window to validate NAT idle timeouts and application-level pings (15–30s).
5. allowedOrigins and trusted-proxy (two layers)
5.1 Who owns Origin checks?
Keep allowedOrigins as a Gateway-owned allowlist (exact matches). The reverse proxy should enforce coarse rules (TLS, method allowlists, optional IP ACLs) but not silently "fix" CORS for the app—dual implementations drift.
5.2 Minimal trusted-proxy set (same-machine proxy)
When Caddy/Nginx and Gateway share the Mac, trust only 127.0.0.1/32 and ::1/128 for the inner hop. If another edge (Cloudflare, Funnel) sits upstream, model that as its own trust zone with explicit IP ranges—never accept arbitrary X-Forwarded-For from the Internet.
6. SSH-headless deployment matrix
| Scenario | Proxy | Certs | Egress pattern | Notes |
|---|---|---|---|---|
| Solo IDE + SSH tunnel | Caddy | mkcert | SSH local forward | Lowest ceremony; document root CA install. |
| Team tailnet | Caddy | Tailscale TLS | Tailnet only | Avoids punching public 80/443 if not required. |
| Public SaaS callbacks | Caddy or Nginx | Let's Encrypt | Direct or tunnel | Tunnel handles discovery; inner proxy still terminates TLS for Gateway. |
| Existing Nginx host | Nginx | Existing stack | Existing stack | Add a server block; avoid double TLS unless intentional. |
6.1 launchd and Homebrew services
Prefer brew services start … or explicit LaunchAgents so proxies survive SSH disconnects. Ordering principle: the proxy may start before Gateway returns 200—configure short upstream retries or health gates in your orchestration doc.
6.2 Headless acceptance snippets
# Process check
ssh user@mac 'pgrep -lf "caddy|nginx" | head'
# Listener on 443
ssh user@mac 'lsof -nP -iTCP:443 -sTCP:LISTEN'
# TLS headers (adjust SNI / resolve)
ssh user@mac 'curl -sI --resolve gw.example.com:443:127.0.0.1 https://gw.example.com/health | head'
# WebSocket upgrade probe (raw backend)
ssh user@mac 'curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGVzdA==" \
http://127.0.0.1:18789/ 2>&1 | head -8'
7. FAQ
Q1: Browser shows "WebSocket is closed before the connection is established"?
A: On Nginx, verify Upgrade/Connection headers and read timeouts. On Caddy, check whether a CDN or tunnel strips Upgrade.
Q2: SSE first byte takes tens of seconds?
A: Buffering is still on (Nginx proxy_buffering or missing flush settings in Caddy). Disable buffering for streaming routes.
Q3: Logs only show the proxy IP in X-Forwarded-For?
A: Expand trusted hop configuration to include the real immediate client of the proxy (loopback for same-box). If Cloudflare fronts you, trust Cloudflare's published ranges or terminate there intentionally.
Q4: CORS says blocked but Origin looks correct?
A: allowedOrigins is usually exact string match—https://gw.example.com differs from https://gw.example.com:443. Fix the allowlist, do not paper over in the proxy.
Q5: Multiple Gateway ports behind one proxy?
A: Use distinct hostnames per environment, each mapping to its own 127.0.0.1:port. Avoid path-based routing for WebSocket-heavy APIs unless every client supports the same path layout.
8. Why Mac mini / macOS for this edge
A reverse proxy plus Gateway is a 24/7 control plane: you want low idle power, quiet thermals, and a service manager you can reason about from SSH alone. macOS gives launchd for deterministic restarts, OpenSSH and Homebrew paths your operators already know, and Apple Silicon Mac mini systems routinely idle in the very low watt range while still delivering strong single-thread headroom for TLS and JSON fan-out.
Gatekeeper, SIP, and FileVault materially reduce drive-by malware risk compared with many commodity Windows images, while unified memory keeps proxy buffers and Gateway workers on the same fast fabric. If you are standardizing OpenClaw ingress on hardware that will not fight you on sleep, thermals, or background daemons, Mac mini M4 is a sensible 2026 baseline—visit the home page via the CTA below to compare plans and ship this matrix on metal that stays efficient when nobody is logged in at the console.