Skip to content

nginx on macOS: root ownership, three layouts ​

An edge's nginx front has to be a root process reading configuration from a tree only root can write — a root-run LaunchDaemon that takes instructions from a config file a login-session user can edit is not a hardened front, it is that user with extra steps. macOS makes this harder to get right than Linux because there are three different places a working nginx binary can come from, and burrowee has to find whichever one is actually running rather than assume one of them.

The three supported layouts ​

LayoutBinaryConf dir (default)Status
Homebrew/opt/homebrew/bin/nginx/opt/homebrew/etc/nginxDefault. What the install docs above recommend.
Hand-built root install/usr/local/sbin/nginx/usr/local/etc/nginxSupported — see the recipe below. Not the recommendation.
MacPorts/opt/local/bin/nginx/opt/local/etc/nginxSupported if that's what's already on the host.

burrowee never hardcodes any of these paths, and it does not defer to the operator's shell PATH to pick one either — a shell can be configured to put any of the three first, and that ordering has no bearing on which nginx a root LaunchDaemon is actually running. Instead it walks a fixed, root-owned-prefix-first search order: /usr/local/sbin, /usr/local/bin, /opt/homebrew/bin, /opt/local/bin. Root-owned prefixes come first deliberately, so a host migrated to the hand-built layout resolves that binary before it ever reaches Homebrew's — even on a shell whose own PATH lists Homebrew's bin first. Whichever binary that search finds is asked for its own --conf-path (nginx -V reports it) and burrowee uses that — the binary is the authority on its own configuration, so the two can never disagree. This means switching layouts, or migrating a host from one to another, needs no flag and no burrowee-side reconfiguration: whatever nginx -V says is what burrowee reconciles against.

The posture that matters ​

nginx's master process runs as root under every one of the three layouts — that was never the gap. What differs is how it gets started, and that's where the real distinction lives:

  • sudo brew services start nginx registers a system-level LaunchDaemon. Launchd starts it at boot, with nobody logged in, and keeps it running.
  • brew services start nginx (no sudo) registers a per-user LaunchAgent instead — a launchd job scoped to a login session. It starts nothing when the host boots headless, and if a LaunchDaemon is also present, the two race each other for the same port at every boot.

So the install step that actually matters on macOS isn't brew install nginx — that part runs the same either way — it's remembering the sudo on brew services start. Everything below assumes a root master, one way or another. burrowee runs as root here too, which is why it has to decide whether the nginx it found is one root may safely execute — see the root-equivalent owner rule next.

Why root may run Homebrew's nginx — the root-equivalent owner rule ​

burrowee runs as root on macOS: the edge and the relay are both system LaunchDaemons, and doctor --fix is typed with sudo. A root process that resolves a program by name has to be careful about which file it ends up executing — the ordinary rule is that the binary and every directory on the way to it must be owned by root and writable by nobody else, because anyone who can write one of them chooses what root runs. Homebrew's prefix is not that: it is owned by the account that installed Homebrew, with group admin and group write left on. Under the strict rule alone, a root burrowee refuses /opt/homebrew/bin/nginx outright — and with it nginx -V, nginx -t, every reload, and brew services itself.

So on macOS a second, narrower rule applies to paths burrowee resolved for itself: root may execute a file when every account that can write it can already become root. For the binary and every directory above it, that means none may be world-writable; each must be owned either by root or by an account belonging to a group sudoers grants ALL=(ALL) ALL; and any that is group-writable must carry one of those same sudoers groups as its group. It is read, not assumed — /etc/sudoers and /etc/sudoers.d/ are parsed at check time, and a host whose sudoers cannot be read, or which grants no such group, falls back to the strict rule.

On a stock Apple-silicon Mac this simply holds: Homebrew's installer creates the prefix as <user>:admin, and macOS's default sudoers carries %admin ALL = (ALL) ALL. The rule grants nothing new, either — every account it trusts can already type sudo -s, and sudo brew services start nginx, the command this page recommends, already hands that exact binary to launchd to run as root.

What it looks like when it applies. The nginx installed row names the posture that accepted the path. A root-owned binary renders exactly as before, with nothing appended:

✓ nginx installed        /opt/homebrew/bin/nginx (admin-writable prefix — root-equivalent)
✓ nginx installed        /usr/local/sbin/nginx

and the acceptance is announced once per process on stderr, so root executing a file a non-root account can write is never silent:

burrowee: running as root — nginx resolved to /opt/homebrew/bin/nginx, which a non-root account can write; accepted because /opt/homebrew/bin is owned by uid <uid> (<user>) and writable by group admin (gid 80), and /etc/sudoers grants %admin ALL=(ALL) ALL — every account that can write it can already become root

What stays refused, and what to do about each ​

  • A prefix owned by an account that is not in admin — a Mac whose Homebrew was installed by an account since removed from the group, or a prefix handed to a service account. The refusal names the owner:

    /opt/homebrew is owned by uid <uid> (<user>), neither root nor a member of a sudoers ALL=(ALL) group

    Either that account belongs in admin and should be put back into it, or it deliberately does not — in which case the refusal is correct, and the answer is a root-owned nginx: the hand-built recipe below, or a MacPorts install.

  • A prefix whose group is staff, which is what a Homebrew prefix shared between several local accounts usually looks like: installed once, chgrp'd to staff, group write left on so every account can brew install. burrowee refuses it:

    binary: candidate is not root-secure: refusing to resolve nginx as uid 0 from /opt/homebrew/bin/nginx (/opt/homebrew/bin is writable by group staff (gid 20), which sudoers grants nothing)

    That refusal is the rule working, not a defect to report. staff is every local account's primary group, so "writable by staff" means "writable by everyone with a login on this host" — including accounts sudoers grants nothing at all, which is precisely the shape the guard exists to refuse. Two remedies, and they are different postures — pick deliberately:

    • Every account sharing the prefix is already an admin member. Then the group is merely narrower than the truth: move the prefix's group to admin (sudo chgrp -R admin <prefix>), keep group write, and clear any world write bit. The same rule then accepts it, the sharing arrangement is unchanged, and every sharer can still brew install.
    • At least one sharer is not an admin member. Then nothing should be widened — that account genuinely is not one the host trusts with what root executes. Run a root-owned nginx instead: the hand-built recipe below, or MacPorts, both of which pass the strict rule with nothing relaxed.

    What not to do in either case is sudo chown -R root the prefix. It makes the strict rule pass, and it breaks every mutating brew command for the account that owns the prefix — the same ownership trade this page already makes for burrowee's own config tree. Homebrew itself takes root ownership of exactly the paths a root daemon needs — the plist, the Cellar binary and its bin — without ever taking the prefix.

  • A world-writable directory anywhere on the chain. Never accepted, under either rule, on any layout. There is nothing to configure here; fix the mode.

  • An operator-named path — a bin_nginx pin, or a bin_path entry — pointing into the prefix. Still refused under the strict rule, and dropped with a notice saying so, even on a host where burrowee's own resolution of that same binary is accepted. The asymmetry is deliberate: a path burrowee resolves for itself is checked at the moment it is used, while a pin is a standing instruction honoured indefinitely — and the account that writes config is typically the unprivileged service account, not an admin member. Pin a root-owned binary, or leave the pin out and let the search find the Homebrew one.

This is macOS only. Nothing about Linux changes. The Linux nginx locations (/usr/sbin, /usr/bin, /usr/local/nginx/sbin) are root-owned on every distribution, so the strict rule already passes there, and the Linux edge daemon reconciles its front through the narrow passwordless-sudo rule with a pinned secure_path rather than by resolving a user-owned prefix as root. /opt/homebrew/bin is never added to that secure_path on any platform: PATH is not what makes a directory trusted.

Why burrowee's own config isn't in the package manager's tree ​

burrowee's fronts (the frontier ssl_preread snippet and the LAN stream snippet) do not live inside /opt/homebrew/etc/nginx, MacPorts' /opt/local/etc/nginx, or the hand-built layout's /usr/local/etc/nginx. They live in their own tree, root-owned, next to but separate from whichever package manager's conf dir is in play:

/usr/local/burrowee/etc/nginx/     root:wheel, 755
├── servers/
└── servers-stream/

nginx.conf gets one absolute include line pointing at it (include /usr/local/burrowee/etc/nginx/servers-stream/*.conf;) rather than a relative one, and burrowee is what writes and owns that tree — not Homebrew, not MacPorts, not the hand-built prefix. The reasoning is ownership, not convenience: if burrowee's snippets lived inside Homebrew's own etc/nginx, a later brew upgrade nginx would need to write into a directory burrowee had taken over, and it would fail for the unprivileged account that owns the Homebrew prefix. Two separate trees, two separate owners, and neither one's maintenance step ever collides with the other's.

Required stream modules — and the trap ​

Whichever layout is in play, its nginx needs to have been built with:

  • --with-stream
  • --with-stream_ssl_module
  • --with-stream_ssl_preread_module
  • --with-stream_realip_module

Homebrew's and MacPorts' formulas ship all four already; this only becomes something to check by hand on a hand-built nginx.

The trap: --with-stream_realip_module is what proxy_protocol on needs, and it is needed at runtime, not at config-test time. Leave it out and nginx -t still reports the config valid — the directive parses fine — but the front fails silently under live traffic the moment a connection actually needs the real client IP preserved. A health check that only runs nginx -t will call a host like this healthy. The only way to be sure is to check the modules nginx -V actually reports, not just that the config test passes.

The hand-built recipe ​

This is the layout to reach for only when the other two don't fit — typically because a package-manager-installed nginx's shared libraries resolve onto a volume that isn't guaranteed to be mounted yet at boot, and a LaunchDaemon that depends on a not-yet-mounted volume fails intermittently. The fix is a self-contained binary with no external dylib dependencies.

The shape of it:

  1. Build as an unprivileged user, not as root — the build step downloads and compiles third-party source, and only the install step needs root. Fetch matched nginx, PCRE2, and OpenSSL source releases and pin all three versions somewhere you'll bump deliberately later; an unpinned build silently drifts to whatever's newest.
  2. Configure with PCRE2 and OpenSSL compiled in (--with-pcre=<path> --with-pcre-jit, --with-openssl=<path>) rather than linked against a package manager's shared libraries — this is what removes the external-volume dependency. Include the four stream modules above, plus the usual http_ssl, http_v2, and http_realip modules. Point --prefix, --sbin-path, and --conf-path at /usr/local and /usr/local/etc/nginx/nginx.conf.
  3. Verify the linkage before installing anything: otool -L on the built binary should show only system libraries under /usr/lib — no /opt/homebrew or /opt/local entries. If one shows up, the --with-openssl/--with-pcre flags didn't take and the build isn't done.
  4. Install as root, migrate the existing conf tree across (copy the live nginx.conf and snippet directories rather than starting from a blank config), and chown -R root:wheel the result.
  5. Test the new config before touching the running service: nginx -t against the new binary and conf tree, with the old nginx still serving. Nothing stops until this passes.
  6. Write the LaunchDaemon plist at /Library/LaunchDaemons/<label>.plist, using a Label you choose (reverse-DNS style, distinct from the package manager's own job labels) rather than any workstation-specific name — root-owned, RunAtLoad + KeepAlive, running nginx -g 'daemon off;'.
  7. Cut over gracefully: ask the old master to quit (nginx -s quit) rather than killing it, so in-flight connections drain instead of dropping; wait for it to actually exit before bootstrapping the new LaunchDaemon, since launchctl bootout is asynchronous and racing it leaves the host with no nginx at all for a moment.
  8. Verify the new master is actually serving — process present, launchd reports it running, the ports respond — before doing anything to the old install.
  9. Leave the old binary in place. Retiring the package manager's launchd job (so it stops racing the new LaunchDaemon for the port at boot) is enough; uninstalling the formula entirely is optional and only worth doing once you're confident in the replacement, because it's also your rollback.

Retiring the old LaunchAgent ​

A host that has been running brew services start nginx without sudo at some point has a ~/Library/LaunchAgents/homebrew.mxcl.nginx.plist sitting in a login-session domain. That's the literal artifact of the "bound to a user" case: it starts nothing at headless boot, and if a system LaunchDaemon is also present (this page's default Homebrew path included, once started correctly) the two race each other for the port every time the host boots.

burrowee edge doctor --fix detects this LaunchAgent, reports it as a doctor row, and offers — consent-gated, same as every other --fix action — to back up the plist and launchctl bootout the job. It never uninstalls the Homebrew formula or removes /opt/homebrew/bin/nginx; those stay in place deliberately, because they're the fastest rollback path if anything about the fronting setup needs to be undone.

The three states after brew install nginx, and the row each one gets ​

Installing the formula leaves a host in one of three states, and burrowee edge doctor has one row for each of them:

  • Never started. Nothing is listening, and nginx running is the ✗ row that says so. --fix installs the formula if it is missing, then starts the service as root — so what gets registered is the system LaunchDaemon, not an agent.

  • Started without sudo. The master is up, so nginx running is a ✓ — and the host is still one headless reboot away from serving nothing, because the job lives in a login session. That gap is what the nginx daemon row exists to close:

    ✗ nginx daemon   master pid <pid> runs as uid <uid> (<user>) — a login-session job, not a system LaunchDaemon; it starts nothing when the host boots with nobody logged in. `doctor --fix` retires gui/<uid>/homebrew.mxcl.nginx and runs `sudo brew services start nginx`. See https://docs.burrowee.com/install/nginx-macos

    --fix does exactly what the row describes: it retires the login-session job for the uid the master is actually running as — measured from the running process, never inferred from who typed sudo — and then restarts nginx through brew services as root, so what comes back is the daemon.

  • Started with sudo, with the old agent still in place. The daemon is correct and the leftover LaunchAgent is the race described above; nginx launchd jobs is the ✗ row, and --fix retires the agent.

When the daemon is the thing running, the row states the property that actually matters — that this survives a boot with nobody logged in:

✓ nginx daemon   root LaunchDaemon homebrew.mxcl.nginx (/Library/LaunchDaemons) — starts at boot with nobody logged in

A root master that isn't Homebrew's — the hand-built layout, or MacPorts — gets no nginx daemon row at all: that daemon is root and not brew services' to describe, and the rows above already cover it. Nor does a host where nginx isn't running, since the nginx running row has already said so.

The installer says the same thing, earlier. The macOS preflight no longer stops at "nginx present and running": when a master is present and running but its uid is not 0, it warns that nginx is running as uid <uid>, not as a system daemon — it starts nothing at headless boot, and offers the sudo brew services start nginx step (with the matching launchctl bootout gui/<uid>/homebrew.mxcl.nginx line) instead of concluding the host is already done. Install time and doctor time tell one story.

Rollback ​

Every layout above is reversible without deleting anything:

  • Homebrew: if sudo brew services start nginx was never run, running it is the whole fix — no rollback needed, since nothing was torn down.
  • Hand-built: the old package-manager nginx (Homebrew's or MacPorts') is left on disk throughout the migration — that's the point of leaving its launchd job merely disabled rather than removing the binary. Reverting is: stop the hand-built LaunchDaemon (launchctl bootout), remove its plist, restore the package manager's launchd job from the backup taken during migration, and start it (sudo brew services start nginx or the MacPorts equivalent).
  • In both directions, burrowee's own front tree at /usr/local/burrowee/etc/nginx/ is untouched by either side of the cutover — only the top-level nginx.conf include target needs to keep pointing at whichever nginx is currently live, and burrowee's conf-dir resolution (nginx -V on the binary actually running) picks that up automatically on the next reconcile.

The CVE cost of the hand-built layout ​

This is the main reason Homebrew stays the recommendation rather than a fallback: a hand-built OpenSSL is tracked by no package manager. Homebrew and MacPorts both flag a formula with a known CVE and prompt an upgrade; a nginx compiled with --with-openssl= against a source tarball has none of that. Choosing the hand-built layout means taking on the OpenSSL (and PCRE2, and nginx itself) patching cadence yourself — watching upstream security advisories and rebuilding on your own schedule, with nothing automated to remind you. Do this only when the layout's boot-ordering benefit is worth that ongoing cost, and budget for checking upstream advisories on a recurring basis, not just once at build time.

See also ​

  • Install edge — the install one-liner and platform notes this page is linked from.
  • nginx front — what burrowee actually writes into whichever conf dir it resolves, and the nginx install/reconcile/apply commands.
  • Config homes & files — the full path reference, including the darwin entries for burrowee's own nginx tree.