Trusting Local HTTPS Certificates With mkcert
The browser shows NET::ERR_CERT_AUTHORITY_INVALID (Chrome) or SEC_ERROR_UNKNOWN_ISSUER (Firefox) on https://app.localhost, and every teammate has clicked "proceed anyway" so many times that nobody notices when a real certificate problem appears. This page replaces per-service self-signed certificates with a local certificate authority the browser actually trusts, as part of the broader local HTTPS and reverse proxy setup.
The fix takes about two minutes per laptop and removes the warning permanently for every hostname the team uses. It also removes the curl -k and verify=False workarounds that tend to creep from local scripts into shared code.
Diagnostic
Confirm the certificate being served is self-signed or signed by an issuer the machine does not trust. openssl s_client shows the chain and the verification result in one call:
#!/usr/bin/env bash
set -euo pipefail
echo | openssl s_client -connect 127.0.0.1:443 -servername app.localhost 2>/dev/null \
| grep -E 'subject=|issuer=|Verify return code'
curl -sS -o /dev/null https://app.localhost/ || echo "curl exit: $?"
Expected bad output:
subject=CN = app.localhost
issuer=CN = app.localhost
Verify return code: 18 (self-signed certificate)
curl: (60) SSL certificate problem: self-signed certificate
curl exit: 60
When subject and issuer are identical, the certificate signed itself. Code 18 (self-signed) or 20 (unable to get local issuer) both mean the same thing for this fix: no trusted authority stands behind the certificate. A code 10 (certificate has expired) is a different problem — an old mkcert leaf past its validity — and is covered in the Prevention section.
Root cause
TLS verification succeeds only when the chain of certificates presented by the server ends at a root certificate the client already trusts. A self-signed certificate is its own root, and no browser ships with your laptop's ad-hoc root in its store. Adding a browser exception papers over one hostname in one browser profile; it does nothing for curl, for other browsers, or for the next hostname. The durable fix is to create one root that the machine trusts — a local CA — and sign every development certificate with it, so every client that consults the system store verifies every local hostname through the same anchor.
There is a second, quieter cost to the self-signed approach. Because the warning appears on every visit, developers learn to click through it, and the habit survives into staging and review environments where a certificate error can mean a genuine misconfiguration or an intercepted connection. Teams that disable verification in code (rejectUnauthorized: false, verify=False, InsecureSkipVerify: true) to make local calls work also create configuration that can be promoted accidentally. A trusted local CA removes the reason for every one of those workarounds, which is why it belongs in the environment baseline rather than in each developer's personal setup notes.
Resolution
- Install mkcert and create the local CA.
-installwrites the CA into the system store, the NSS database used by Firefox and Chromium on Linux, and the Java keystore if a JDK is found.
#!/usr/bin/env bash
set -euo pipefail
case "$(uname -s)" in
Darwin) brew install mkcert nss ;;
Linux) sudo apt-get update && sudo apt-get install -y mkcert libnss3-tools ;;
esac
mkcert -install
mkcert -CAROOT
- Generate one certificate that covers every hostname the stack serves. A wildcard covers one label only, so
*.localhostmatchesapi.localhostbut notv2.api.localhost; list deeper names explicitly.
#!/usr/bin/env bash
set -euo pipefail
mkdir -p .certs
mkcert -cert-file .certs/local.pem -key-file .certs/local-key.pem \
localhost "*.localhost" "*.api.localhost" 127.0.0.1 ::1
chmod 600 .certs/local-key.pem
- Point the proxy or dev server at the new files and restart it. For Traefik this is the TLS file provider; for Vite it is
server.https; for a plain Node server it is thekeyandcertoptions tohttps.createServer.
#!/usr/bin/env bash
set -euo pipefail
docker compose restart proxy
Restart the browser completely. Chrome and Firefox cache the trust decision per process; a new tab is not enough.
Record the certificate generation as a bootstrap step so new hires never do it by hand — the one-command make bootstrap target is the natural home for it.
certs: .certs/local.pem
.certs/local.pem:
@command -v mkcert >/dev/null || { echo "install mkcert first"; exit 1; }
mkcert -install
mkdir -p .certs
mkcert -cert-file .certs/local.pem -key-file .certs/local-key.pem localhost "*.localhost" 127.0.0.1 ::1
Expected output
$ echo | openssl s_client -connect 127.0.0.1:443 -servername app.localhost 2>/dev/null | grep -E 'issuer=|Verify return code'
issuer=O = mkcert development CA, OU = dev@laptop, CN = mkcert dev@laptop
Verify return code: 0 (ok)
$ curl -sS -o /dev/null -w '%{http_code}\n' https://app.localhost/
200
The issuer is now the mkcert CA, verification returns 0 (ok), and curl succeeds without -k. In the browser, the padlock shows "Connection is secure" and the certificate viewer lists the mkcert CA as the root.
If the browser still warns while openssl reports 0 (ok), the browser is reading a different trust store than the one mkcert wrote to. On Linux this is almost always a Snap or Flatpak browser with its own sandboxed NSS database; on Windows it is usually Firefox, which ignores the Windows store unless security.enterprise_roots.enabled is set to true in about:config. In both cases the CA is correct and only the store needs updating — do not regenerate the certificates.
Prevention
- Fail fast on expiry. mkcert leaf certificates are valid for a little over two years. Add a check to the onboarding health-check script so an expired leaf is reported with a fix, not discovered as a browser warning:
#!/usr/bin/env bash
set -euo pipefail
if ! openssl x509 -checkend $((30*24*3600)) -noout -in .certs/local.pem; then
echo "local certificate expires within 30 days: run 'make certs' after deleting .certs/"
exit 1
fi
Never commit the CA key. Add
.certs/to.gitignoreand addrootCA-key.pemto the gitleaks pre-commit hook path rules so a copied key is blocked at commit time.Derive hostnames from Compose. Generate the SAN list from the
Host()labels incompose.yamlso a new service cannot ship with a name the certificate does not cover.
Platform caveats
macOS:
mkcert -installprompts for the login keychain password and adds the CA to the System keychain. Safari and Chrome use it immediately after a restart; Firefox needsnssinstalled via Homebrew or it silently keeps warning.
WSL2: browsers run on Windows, so the CA must live in the Windows store. Install mkcert on Windows, run
mkcert -installthere, then setCAROOTin WSL to the Windows CA directory before runningmkcertinside WSL to sign leaves.
Apple Silicon (ARM64): the Homebrew build is native arm64; no Rosetta is involved. If
mkcertreportsBad CPU type, an x86 binary was copied from another machine — reinstall with Homebrew.
Linux: Snap-packaged Chromium and Firefox use a sandboxed NSS database under
~/snap/. Runmkcert -installafter first launching the snap browser, or importrootCA.pemthrough the browser's certificate settings.
Rollback
Remove the CA from every trust store and delete the generated leaves. Services fall back to whatever certificate the proxy had before.
#!/usr/bin/env bash
set -euo pipefail
mkcert -uninstall
rm -rf .certs "$(mkcert -CAROOT)"
Frequently Asked Questions
Can the whole team share one mkcert CA through the repository?
Do not do that. The CA key can sign trusted certificates for any domain on every machine that installed it, so committing it turns a local convenience into a credential that must be guarded like a production secret. Each developer running mkcert -install costs seconds and keeps the key on one machine.
Why does *.localhost not cover v2.api.localhost?
Wildcards in TLS certificates match exactly one DNS label. *.localhost matches api.localhost but not a name with two labels before localhost. Add *.api.localhost or the exact name to the mkcert command and regenerate.
Does mkcert work for mobile devices testing against my laptop?
Yes, but the device must trust the CA too. Copy rootCA.pem (never the key) to the phone, install it as a profile, and on iOS enable full trust under Certificate Trust Settings. Generate the leaf for the laptop's LAN IP as well as the hostnames.