Running Apache and Nginx side by side on one Mac
Only one process can hold port 80. That single fact drives every decision in this setup — and explains most of the ways it goes wrong.
Why you would want both
Most stacks pick one server and stay there. But a machine that has been used for a few years rarely has one kind of project on it. A legacy WordPress site expects Apache's .htaccess and mod_rewrite. A Next.js or FastAPI app is documented, deployed and debugged against Nginx. Rewriting either one's configuration to match the other server is work with no payoff.
The alternative is to run both and route each domain to the server it was written for.
The port problem
Ports 80 and 443 can be bound by exactly one process each. Whichever server starts first wins; the second fails with Address already in use and, if you started it through a service manager, may keep retrying quietly in the background.
The convention that works is to give the well-known ports to one server and move the other up:
| Server | HTTP | HTTPS | URL looks like |
|---|---|---|---|
| Apache | 80 | 443 | https://legacy.test |
| Nginx | 8080 | 8443 | https://app.test:8443 |
The visible cost is the port in the URL for the second server's sites. It is cosmetic, but it does leak into anything that generates absolute URLs — frameworks that build links from HTTP_HOST usually handle it, ones that hardcode a scheme and host do not.
Do not try to solve this with a single front proxy unless you need to. Putting Nginx on 80 and proxying some hosts to Apache does remove the port from every URL, but it also means every Apache request now passes through Nginx, and you debug two servers whenever one request misbehaves. For local development that trade is rarely worth it.
Checking what actually holds the port
Before changing configuration, find out what is listening. This is the single most useful command in the whole exercise:
$ sudo lsof -nP -iTCP:80 -sTCP:LISTEN
COMMAND PID USER FD TYPE DEVICE NODE NAME
httpd 1234 root 4u IPv6 0x... TCP *:80 (LISTEN)
If nothing is listed, the server is not running — which is not the same as misconfigured, and the fix is different. If something unexpected is listed, you have found your conflict.
One certificate covers both
Certificates bind to hostnames, not ports. A certificate issued for app.test is equally valid on 443 and on 8443, so moving a server to a non-standard port changes nothing about TLS. See the local HTTPS guide for how the certificates themselves are issued and trusted.
What does differ is how each server is told about them:
# Apache — certificate and key as separate directives
SSLCertificateFile /path/app.test.pem
SSLCertificateKeyFile /path/app.test-key.pem
# Nginx — the certificate file is expected to contain the chain
ssl_certificate /path/app.test.pem;
ssl_certificate_key /path/app.test-key.pem;
Apps behind a reverse proxy
PHP is served directly, but Node, Python and .NET apps listen on their own port and sit behind whichever server owns the domain. That adds a second failure mode worth naming: the app can be running perfectly while the site is unreachable, because the server in front of it is stopped.
It also inverts the startup order. Starting the proxy before the app produces a 502 until the app comes up; starting the app first is cleaner. If something starts them for you, that ordering is worth checking.
# Nginx, in front of an app on 3001
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
Those two headers matter more than they look. Without Host, the app sees 127.0.0.1 and generates links pointing at itself. Without X-Forwarded-Proto, a framework that terminates TLS at the proxy thinks the request arrived over HTTP and may redirect in a loop.
The reload trap
Both servers re-read their configuration on reload rather than restart, and both expand include globs at that moment. So a newly added virtual host file is picked up by a reload — you do not need to restart.
nginx.conf: include sites-available/*.conf;
httpd.conf: Include */extra/httpd-vhosts.conf
The trap is the check that usually guards the reload. Scripts commonly test whether the server is running before reloading it, and the obvious test is wrong for Nginx:
$ pgrep -x nginx # no output — but Nginx is running
$ ps -eo comm= | grep '^nginx'
nginx: master process /opt/homebrew/opt/nginx/bin/nginx -g daemon off;
The Nginx master process rewrites its own title, so an exact-name match never finds it. Apache is unaffected — its process really is called httpd. A script using pgrep -x silently skips the reload, the new vhost never loads, and the symptom is "a new domain only appears after I restart Nginx by hand."
Always validate before reloading. apachectl configtest and nginx -t both report syntax errors without touching the running server. Reloading a broken configuration can leave you with a server that stopped and will not come back.
When it looks broken
The wrong site loads
Both servers fall back to their first or default virtual host when no ServerName matches. Seeing an unrelated project — or the stock welcome page — usually means the hostname never matched, not that the vhost is malformed.
It works on one port but not the other
Check that the vhost actually listens on the port you are testing. A block declared for *:443 answers nothing on 8443, and the browser reports a connection failure rather than a configuration error.
Changes have no effect
Either the reload was skipped (see above) or you edited a file that is not included. Confirm with the server itself: apachectl -S lists the vhosts Apache actually loaded, and nginx -T prints the entire effective configuration.
How BRAMPP handles it
BRAMPP runs both servers with this split by default and lets you choose, per domain, which one serves it. It writes the vhost, issues the certificate, adds the hosts entry and reloads the correct server — and because it starts a domain's bound web server before the app behind it, the reverse-proxy ordering above is handled rather than left to chance.
Free and open source under the MIT license. It does not bundle its own Apache or Nginx: it drives the Homebrew formulas already installed on your machine.
← Back to BRAMPP