A TLS error that says the certificate is untrusted almost never means the certificate is “broken”. The trust anchor is in the wrong store.
curl, openssl, Git, Python, and the browser are different clients. Some keep their own root lists. Updating the OS bundle on Linux will not fix Chrome or Firefox by itself.
What to import
You trust the CA root that signed the server certificate, not localhost.crt / app.example.internal itself.
Typical files:
| File | Role |
|---|---|
ca.crt | root (trust anchor) — this is what you import |
server.crt | service certificate signed by the CA |
server.key | service private key, server-side only |
A self-signed server certificate with no separate CA can be added as an anchor on its own. For labs and internal PKI you usually keep a CA: one root, many services.
PEM (-----BEGIN CERTIFICATE-----) or DER both work. Most OS tools expect PEM. Browsers and certutil accept DER as well.
The system store
This is what OpenSSL-based clients use: curl, wget, git, many agents. Do this first, then debug the browser.
Linux
Distros rebuild /etc/ssl/certs in different ways.
Debian, Ubuntu, and derivatives — a .crt file under /usr/local/share/ca-certificates/, then rebuild the bundle:
sudo cp ca.crt /usr/local/share/ca-certificates/internal-ca.crt
sudo update-ca-certificates
RHEL, Fedora, Alma, Rocky — drop the anchor, then extract:
sudo cp ca.crt /etc/pki/ca-trust/source/anchors/internal-ca.crt
sudo update-ca-trust extract
Alpine — package ca-certificates, same update-ca-certificates, directory /usr/local/share/ca-certificates/.
Then:
curl -I https://app.example.internal
openssl s_client -connect app.example.internal:443 -servername app.example.internal </dev/null 2>/dev/null | openssl x509 -noout -issuer -subject
If curl is clean and the browser is not, the OS trust is fine. The browser is looking elsewhere.
macOS
Use the System keychain, not the login one, if every process on the machine should trust the CA:
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain ca.crt
For a single user, the login keychain is enough: no sudo, ~/Library/Keychains/login.keychain-db. GUI: Keychain Access → System → Certificates → import → Always Trust for SSL.
Windows
The Local Machine Root store:
certutil -addstore -f Root ca.crt
Same via certmgr.msc (current user) or certlm.msc (computer): Trusted Root Certification Authorities → import.
In a container or CI job the bundle lives inside the image. Installing a CA on the host does not help curl in the container. Copy ca.crt into the image and run the same update-ca-certificates / update-ca-trust at build time.
Why the browser still complains
Chrome takes public roots from the Chrome Root Store. Extra CAs come from the OS — but not on every platform.
| Client | Where your CA has to live |
|---|---|
curl / OpenSSL | OS bundle |
| Chrome / Edge on Windows and macOS | OS store (system install is often enough) |
| Chrome / Chromium on Linux | per-user NSS database, not /etc/ssl/certs |
| Firefox | per-profile store; OS roots only via policy, and not on Linux |
“Installed the CA on the system” and “opened the site in a browser” are separate steps.
Chrome, Chromium, Edge
GUI is the same idea on every OS: Settings → Privacy and security → Security → manage certificates. Authorities tab → import ca.crt. Trust for website identification is enough.
Direct URL: chrome://settings/certificates (Edge: edge://settings/certificates).
CLI on Linux. Chromium/Chrome use an NSS Shared DB. Since M146 the default is ~/.local/share/pki/nssdb; if ~/.pki/nssdb already exists, that one wins.
certutil comes from libnss3-tools (Debian family) or nss-tools (RHEL/Fedora/Alpine).
NSSDB="${HOME}/.pki/nssdb"
[ -d "$HOME/.local/share/pki/nssdb" ] && NSSDB="${HOME}/.local/share/pki/nssdb"
mkdir -p "$NSSDB"
certutil -d "sql:${NSSDB}" -N --empty-password 2>/dev/null || true
certutil -d "sql:${NSSDB}" -A -t "C,," -n "Internal CA" -i ./ca.crt
certutil -d "sql:${NSSDB}" -L
The three -t fields are SSL, email, and code signing. C means trusted CA. Need client-certificate issuance as well — "CT,,". A self-signed server cert with no CA — "P,,".
Fully quit the browser and open it again. Background Chrome processes will not pick up the new cert.
On Windows and macOS a separate NSS import is usually unnecessary: the OS store is enough.
Firefox
Its own database per profile. Snap/Flatpak/vendor builds use different paths — importing into a “normal” profile does not reach them.
GUI: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import. Enable trust for identifying websites.
CLI — same certutil, profile directory, not ~/.pki/nssdb.
Linux: ~/.mozilla/firefox/<id>.default-release/
macOS: ~/Library/Application Support/Firefox/Profiles/<id>.default-release/
Windows: %APPDATA%\Mozilla\Firefox\Profiles\<id>.default-release\
PROFILE=$(find ~/.mozilla/firefox -maxdepth 1 -type d -name '*.default-release' | head -n 1)
certutil -d "$PROFILE" -A -t "C,," -n "Internal CA" -i ./ca.crt
Substitute the right profile path if you have more than one.
Policy: one file for every profile
For a fleet, policies.json beats hand imports.
Policy location:
| OS | Where to put it |
|---|---|
| Linux | /etc/firefox/policies/policies.json or distribution/policies.json in the install dir |
| macOS | Firefox.app/Contents/Resources/distribution/policies.json |
| Windows | distribution\policies.json next to firefox.exe, or GPO |
ImportEnterpriseRoots makes Firefox trust roots from the OS store. It works on Windows and macOS. Mozilla does not implement it on Linux: there is no OS certificate store in the same sense.
On Linux (and as an explicit list on any OS) use Certificates.Install: a filename or an absolute path. A bare filename is searched here:
- Linux:
/usr/lib/mozilla/certificates,/usr/lib64/mozilla/certificates,~/.mozilla/certificates - macOS:
/Library/Application Support/Mozilla/Certificates,~/Library/Application Support/Mozilla/Certificates - Windows:
%LOCALAPPDATA%\Mozilla\Certificates,%APPDATA%\Mozilla\Certificates
{
"policies": {
"Certificates": {
"Install": ["internal-ca.crt"]
}
}
}
PEM and DER both work. Restart Firefox after changing the policy.
security.enterprise_roots.enabled in about:config is the same as ImportEnterpriseRoots: Windows and macOS, not Linux. On Linux, Certificates.Install or the p11-kit-trust PKCS#11 module is the equivalent.
CLIs and runtimes that still ignore the OS
Even after the system CA is in place, parts of the stack ship their own bundle.
| Stack | What it uses | What to set |
|---|---|---|
Python (certifi, some requests/httpx) | its own Mozilla bundle | SSL_CERT_FILE, REQUESTS_CA_BUNDLE, or truststore |
| Node.js | its own list | NODE_EXTRA_CA_CERTS=/path/to/ca.crt |
| Java | JDK cacerts | keytool -importcert -alias internal-ca -file ca.crt -keystore "$JAVA_HOME/lib/security/cacerts" |
| Git | often OS OpenSSL/Schannel | OS CA; otherwise http.sslCAInfo |
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/internal-ca.pem
macOS and Windows use different paths for the system bundle; for Node it is simpler to point at ca.crt itself.
Short checklist
- Import the CA, not the service leaf.
- Put it in the OS store and verify with
curl/openssl s_client. - Chrome on Linux needs NSS as well; on Windows/macOS step 2 is often enough.
- Firefox needs a profile import or
policies.json;ImportEnterpriseRootsis Windows/macOS only. - In a container, a JDK, and Node, check that runtime’s bundle, not only the OS.
There is no single command that covers all of this. There is a predictable order: OS → browser → runtime.
