Local HTTPS on macOS, and why the padlock is harder than it looks
Modern browser APIs refuse to run over plain HTTP. So your local site needs a certificate — but a certificate alone is not enough, and the reason is the part most guides skip.
Why you suddenly need HTTPS locally
Service workers, the Clipboard API, getUserMedia, Web Crypto's subtle interface, HTTP/2 in practice — all of them are gated behind what browsers call a secure context. http://localhost is treated as one by special dispensation. http://myproject.local is not.
The moment you move off localhost and onto a real hostname — which you do as soon as you have more than one project, or need subdomains, or want cookies scoped the way production scopes them — you need a real certificate.
A self-signed certificate is not the answer
The obvious move is openssl req -x509. It produces a certificate, the server accepts it, and the browser rejects it anyway.
The reason is that browsers do not evaluate certificates on their own merits. They walk a chain of trust: this certificate was signed by that authority, which was signed by another, up to a root the operating system already trusts. A self-signed certificate is its own root, and nothing trusts it.
You can click through the warning. You should not build a habit of it — and service workers will still refuse to register, because a click-through is not the same as a valid chain.
What actually works: your own certificate authority
The fix is to become a certificate authority for your own machine. Create one root certificate, tell macOS to trust it, and then issue as many site certificates as you like from it. Each one chains back to a root the system already accepts, so browsers stop complaining.
mkcert does exactly this and nothing more. Its root lives in one place:
$ mkcert -CAROOT
/Users/you/Library/Application Support/mkcert
$ ls "$(mkcert -CAROOT)"
rootCA-key.pem rootCA.pem
rootCA.pem is the public certificate that gets installed into the system trust store. rootCA-key.pem is the private key that signs your site certificates.
Treat that private key like a password. Anyone holding rootCA-key.pem can mint a certificate for any hostname — google.com included — that your Mac will trust without a murmur. It never leaves your machine, never goes in a repository, and never gets copied to a second computer "to save time".
Why macOS asks for your password once
Installing the root writes into the System keychain, not your login keychain. That is a privileged store, so macOS prompts for an administrator password exactly once, when the CA is first installed. Every certificate issued afterwards is signed locally and needs no prompt at all.
If you are ever asked repeatedly, something is re-installing the CA on every run — that is a bug in the tooling, not normal behaviour.
The .local trap
Most tutorials tell you to use myproject.local. It usually works on macOS. It is also the one choice with a standards problem underneath it.
RFC 6762 reserves .local for Multicast DNS — Bonjour, the mechanism that finds printers and AirPlay targets on your network. On macOS, an entry in /etc/hosts does take precedence, so it resolves:
$ ping -c1 myproject.local
PING myproject.local (127.0.0.1): 56 data bytes
But you are borrowing a namespace that belongs to something else. On other operating systems, inside containers, or on VPNs that intercept mDNS, the same hostname can resolve slowly or not at all.
RFC 6761 reserves .test for exactly this purpose: names that are guaranteed never to be delegated on the public internet, intended for testing. It has no second meaning, no multicast responder listening for it, and no chance of colliding with a real domain you buy later.
Prefer .test for new projects. myproject.test behaves identically on macOS and avoids the ambiguity everywhere else. Keep .local if an existing project already depends on it — there is no urgency to migrate, only a reason not to start there.
The three pieces that have to line up
A working local HTTPS setup is not one thing but three, and a failure in any of them looks the same from the browser:
| Piece | What it does | How it fails |
|---|---|---|
/etc/hosts | Points the hostname at 127.0.0.1 | Browser cannot reach the site at all |
| Certificate | Proves the server is that hostname | Warning page, or ERR_CERT_COMMON_NAME_INVALID |
| Virtual host | Tells the server which files to serve on 443 | Wrong site, or the default page |
The certificate has to name the host exactly. A certificate for myproject.test does not cover api.myproject.test — you need it listed as an additional name, or a wildcard.
Apache and Nginx do not agree on ports
Only one process can hold port 443. If you run both servers — and there are good reasons to, such as keeping a WordPress site on Apache while a Next.js app sits behind Nginx — one of them has to move.
The usual split is Apache on 80/443 and Nginx on 8080/8443. That works, but it means the Nginx sites carry a port in their URL: https://myapp.test:8443. Certificates do not care about ports, so the same certificate covers both.
The configuration differs in one detail worth remembering: Apache wants the certificate and key as two separate directives, while Nginx wants the certificate file to already contain the chain.
# Apache
SSLCertificateFile /path/myapp.test.pem
SSLCertificateKeyFile /path/myapp.test-key.pem
# Nginx
ssl_certificate /path/myapp.test.pem;
ssl_certificate_key /path/myapp.test-key.pem;
Reading the failures
The browser still warns after installing the CA
Almost always a browser that keeps its own trust store, or one that cached the old certificate. Firefox uses its own store by default — mkcert can install into it, but only if Firefox is present when the CA is installed. Restart the browser before assuming the setup is wrong.
It works in Safari but not in Chrome
Chrome is stricter about certificate lifetime. Certificates valid for more than 398 days are rejected outright. mkcert stays under that limit; hand-rolled OpenSSL commands with -days 3650 do not.
Service worker still refuses to register
A valid certificate is necessary but not sufficient — the page must also be served over HTTPS, with no mixed content. One http:// script tag is enough to drop the page out of secure context.
Doing it by hand, once
For a single project the whole thing is four commands:
brew install mkcert
mkcert -install # asks for your password, once
mkcert myapp.test "*.myapp.test" # writes the .pem pair here
sudo sh -c 'echo "127.0.0.1 myapp.test" >> /etc/hosts'
Then point the vhost at the two files and reload the server. The friction is not in doing it once — it is in doing it for the eleventh project, remembering which certificate belongs to which vhost, and keeping the hosts file honest when projects come and go.
How BRAMPP handles it
BRAMPP runs those same steps for you when you add a domain: it creates the vhost, issues the certificate through mkcert, adds the hosts entry, and reloads the right web server. Nothing is hidden — every command it runs is printed in the console as it runs, and the certificates are ordinary mkcert files you can inspect or reuse.
It is free and open source under the MIT license, and it does not bundle its own Apache, PHP or MySQL: it drives the Homebrew services already on your machine.
← Back to BRAMPP