hardware/mac-studio/services/nginx.md
Table of Contents
Nginx — Central Reverse Proxy (Mac Studio)
Host: Mac Studio — 192.168.1.189
Install: Homebrew — /opt/homebrew/etc/nginx
Binary: /opt/homebrew/opt/nginx/bin/nginx
Config root: /opt/homebrew/etc/nginx/nginx.conf + /opt/homebrew/etc/nginx/sites-enabled/*.conf
As of 2026-09-20, this Mac Studio is the central nginx server for all *.dph.am (and some qwestly.co) traffic. The router forwards ports 22, 80, and 443 to this machine's LAN IP (192.168.1.189), and wildcard DNS (*.dph.am) points at the home's public IP. Any subdomain that doesn't have its own sites-enabled conf falls through to a default block serving ~/Work.
This file is the current source of truth for the nginx server setup itself. ../overview.md and nginx-auth-service.md have been updated (2026-09-20) to point here instead of describing nginx as living on the old Mac Mini dev-server (192.168.1.185).
Related docs:
nginx-auth-service.md— the auth_request login wall and per-app backend list.nginx-pwa-injection.md— how self-hosted apps get PWA manifests/icons and injected UX scripts viasub_filter, served from~/Work/pwa/<app>/.
Why nginx moved here
The nginx config (sites-enabled/, ~40 site .conf files) was copied over from the old Mac (dev-server, z.dph.am / 192.168.1.185) to make this Mac Studio the new central server. Certbot and its certs (/etc/letsencrypt) had to be migrated too — see below.
Certbot / SSL
- Installed via Homebrew:
brew install certbot→ binary at/opt/homebrew/bin/certbot - Cert store:
/etc/letsencrypt(standard path,0700 root, not Homebrew-prefixed) - How certs got here: the old Mac (
z.dph.am, SSH port 22, userdominick) held the real certs. Copied over via tarball (plainrsynccan't read root-owned dirs remotely without a TTY for the remote sudo password):# on old Mac (interactive, so sudo can prompt): ssh -p 22 dominick@z.dph.am sudo tar -czf /tmp/letsencrypt-backup.tgz -C /etc letsencrypt sudo chown dominick /tmp/letsencrypt-backup.tgz # on this Mac: scp -P 22 dominick@z.dph.am:/tmp/letsencrypt-backup.tgz /tmp/ sudo tar -xzf /tmp/letsencrypt-backup.tgz -C /etc - Each site's
.confreferences/etc/letsencrypt/live/<domain>/fullchain.pem+privkey.pem, plus the sharedoptions-ssl-nginx.confandssl-dhparams.pem— all came over in that same tarball.
Running nginx as root (critical gotcha)
Homebrew's default nginx install runs as a user-level LaunchAgent (~/Library/LaunchAgents/homebrew.mxcl.nginx.plist), started as your login user. That's fundamentally incompatible with this setup:
- Can't read
/etc/letsencrypt/live/*— Certbot locks it0700 root. - Can't reliably bind ports
<1024(80/443) long-term.
Fix: run nginx as a real root LaunchDaemon.
brew services stop nginx # kill the user-level agent
sudo pkill -f "nginx: master process"
sudo pkill -f "nginx: worker process"
sudo brew services start nginx # registers /Library/LaunchDaemons/sh.brew.nginx.plist
Gotcha: Bootstrap failed: 5: Input/output error
Hit this twice. Root causes, in order encountered:
- Port already held by the stale user-level master —
bootout/pkillit first (see above). - Plist ownership — Homebrew writes
/Library/LaunchDaemons/sh.brew.nginx.plistasroot:admin.launchdwantsroot:wheelfor system daemons. Fix:sudo launchctl bootout system/sh.brew.nginx # clear stale/half-registered state sudo chown root:wheel /Library/LaunchDaemons/sh.brew.nginx.plist sudo chmod 644 /Library/LaunchDaemons/sh.brew.nginx.plist sudo launchctl bootstrap system /Library/LaunchDaemons/sh.brew.nginx.plist
Verify:
ps aux | grep "[n]ginx" # master should show USER root
sudo lsof -nP -iTCP:80 -iTCP:443 -sTCP:LISTEN # both ports LISTEN
sudo launchctl print system/sh.brew.nginx | grep state # "state = running"
Gotcha: worker runs as nobody → 403s on ~/Work
With no explicit user directive, an nginx master started as root defaults its workers to nobody. nobody can't traverse into /Users/dominick/... (home dir isn't world-readable), so anything serving out of ~/Work (the default/fallback site) 403s.
Fix — add to the top of nginx.conf:
user dominick staff;
Then force a full daemon restart (not just nginx -s reload) so workers respawn under the new user:
sudo nginx -t
sudo launchctl kickstart -k system/sh.brew.nginx
sites-enabled load order matters
nginx.conf includes sites-enabled/*.conf in alphabetical/glob order. Digits sort before _ in ASCII, so 0.dph.am.conf loads before _localhost.conf. Since neither had default_server set explicitly, nginx picked whichever loaded first as the implicit default for port 80 — which silently changed behavior (unmatched hostnames started hitting 0.dph.am's return 404 instead of falling through to the ~/Work catch-all) once 0.dph.am.conf actually got loaded (see next section).
Fix: _localhost.conf explicitly declares itself default:
server {
listen 80 default_server;
server_name localhost;
...
}
There's a separate, deliberate default_server for port 443 in sites-enabled/default.conf — catches unmatched HTTPS hosts and returns 444 (connection closed, no response). That one's fine as-is.
The "why was 443 refused at all" saga
Short version: there was a stale nginx master process running since 2026-09-15 (well before the certs/configs were copied over), started under the old user-level LaunchAgent. Every nginx -s reload after that failed silently with emerg: cannot load certificate ... Permission denied (because non-root can't read /etc/letsencrypt), so the ancient in-memory config — from before most sites-enabled files existed — kept serving. It had no listen 443 block at all, hence "connection refused" on HTTPS despite the router correctly forwarding 443. Killing that stale process and moving to the root LaunchDaemon (above) was the actual fix, not just fixing the referenced cert paths.
Lesson: if nginx -t passes but behavior doesn't match the config on disk, check ps for how long the running master has actually been up — a reload can fail silently and leave a very old process serving forever.
Gotcha: proxy_pass to a LAN IP fails with No route to host (macOS Local Network Privacy)
Symptom: 502s on any site proxying to a real LAN address, error log shows
connect() to 192.168.1.x:PORT failed (65: No route to host), while curl to the exact same address from a shell on the same box works fine. tcpdump shows zero packets for nginx's attempt.
Cause: macOS 15+ (confirmed on 26.6.2) gates outbound connections to RFC1918 addresses per-process, in the kernel. Denial returns EHOSTUNREACH and emits no packets. Grants attach to the responsible process — a shell-run nginx inherits the terminal's grant and works, but the launchd-spawned daemon is its own responsible process with no GUI session, so it's denied by default. Denials are not logged anywhere.
Fix: System Settings → Privacy & Security → Local Network → enable nginx. Applies immediately, no restart needed.
How to confirm it's this (not the config, not a firewall): run the same nginx binary from an interactive shell against a minimal conf that proxies to the LAN target — if the shell-run copy gets a 200 while the daemon 502s, it's the permission gate and nothing else.
mkdir -p /tmp/lnx/logs && cat > /tmp/lnx/nginx.conf <<'EOF'
worker_processes 1; daemon off;
error_log /tmp/lnx/logs/error.log info; pid /tmp/lnx/nginx.pid;
events { worker_connections 64; }
http { access_log off; server { listen 127.0.0.1:8099;
location / { proxy_pass http://192.168.1.153:30141; proxy_connect_timeout 3s; } } }
EOF
/opt/homebrew/opt/nginx/bin/nginx -p /tmp/lnx -c /tmp/lnx/nginx.conf &
curl -so /dev/null -w "shell nginx: %{http_code}\n" --max-time 5 http://127.0.0.1:8099/
curl -sko /dev/null -w "daemon : %{http_code}\n" --max-time 5 -H "Host: pi-win.dph.am" https://127.0.0.1/
A brew upgrade of nginx changes its ad-hoc signature and may drop the grant — if LAN proxying breaks right after an upgrade, check this pane first. Machine-wide context (applies to every third-party daemon on this box, not just nginx): hardware/mac-studio/local-network-privacy.md.
Quick reference
# check status
ps aux | grep "[n]ginx"
sudo launchctl print system/sh.brew.nginx | grep -i state
sudo lsof -nP -iTCP:80 -iTCP:443 -sTCP:LISTEN
# reload config (safe, no downtime)
sudo nginx -t && sudo nginx -s reload
# full restart (needed after nginx.conf top-level changes, e.g. `user` directive)
sudo nginx -t && sudo launchctl kickstart -k system/sh.brew.nginx
# add a new certbot-managed site
sudo certbot --nginx -d newsite.dph.am # or certonly + manual nginx conf if DNS-01/cloudflare
Open items / not yet done
Confirm OpenClaw's— done 2026-09-20, took two passes: first bumpedtrusted-proxyIP allowlisttrustedProxiesto192.168.1.189(nginx's new LAN IP), but OpenClaw'strusted_proxy_local_interface_sourceguard rejects trusting an IP that's one of the app's own local interfaces (nginx + OpenClaw are now same-host). Real fix: routemax.dph.am.conf'sproxy_passthrough127.0.0.1(true loopback) and settrustedProxies: ["127.0.0.1", "::1"]+trustedProxy.allowLoopback: true+trustedProxy.allowUsers: ["dominick"]in~/.openclaw/openclaw.json. Full writeup innginx-auth-service.md. Confirmed working —max.dph.am200, authenticated session overremote=127.0.0.1.- Confirm Certbot renewal (
certbot renew) works end-to-end now that nginx runs as root via LaunchDaemon — the renewal hook that reloads nginx may reference the old user-level service. - Homebrew flagged that upgrading/reinstalling
nginxwill need manualsudo rmon some paths it tookroot:adminownership of (/opt/homebrew/Cellar/nginx/...,/opt/homebrew/opt/nginx,/opt/homebrew/var/homebrew/linked/nginx) — expect friction on the nextbrew upgrade nginx.