Add Your Own CA to Ubuntu's Trust Store
Create a private CA with openssl, sign a leaf certificate, then install the root in /usr/local/share/ca-certificates so Ubuntu trusts your internal HTTPS.
Add your own CA to Ubuntu's trust store
To add your own CA to Ubuntu's trust store, copy the root certificate into /usr/local/share/ca-certificates/ under a name ending in .crt, then run sudo update-ca-certificates. A CA (certificate authority) is a key pair whose certificate is allowed to sign other certificates. Once the machine trusts your root, every certificate that root signed is accepted, so HTTPS between your own services stops failing verification.
This guide builds the whole chain offline with openssl. You create a root key and a root certificate, issue one leaf certificate for a server, then install the root and watch the same verification command change its answer. That order is the point: verifying before and after the install is how you see that the install is what changed the result.
Ubuntu 24.04 ships OpenSSL 3 and the ca-certificates package on a default image, so there is nothing to install first (checked August 2026).
When should you run your own CA?
A public CA such as Let's Encrypt needs a name in public DNS and a server it can reach. Internal names do not qualify. A database on a private network or an admin panel bound to a tunnel cannot get a public certificate, and neither should be exposed to the internet just to earn one.
A self-signed certificate on Ubuntu solves exactly one host. Every client has to trust that one certificate, and the next host starts the same work again. A private CA moves the decision up one level. Clients trust the root once, and every certificate the root signs afterwards is trusted, including certificates for hosts that do not exist yet.
The cost is real. The root key can sign anything the constraints allow, so whoever reads ca.key can issue certificates your machines will accept. Guard it the way you guard a private key in SSH key management. If a service has a public DNS name, skip all of this and use a public CA: Certbot with nginx and Let's Encrypt is less work and needs nothing installed on the client side.
Create the CA key and root certificate
Work in a directory only your user can open. The root key never leaves it.
install -d -m 700 ~/ca
cd ~/ca
openssl genrsa -aes256 -out ca.key 4096
chmod 600 ca.key-aes256 encrypts the key with a passphrase you choose, and every later command that signs with this key asks for it. Leave -aes256 off and the key sits on disk in the clear, so a backup or a second admin account is enough to hand someone the ability to issue certificates your machines trust.
Now the root certificate, which the CA key signs for itself.
openssl req -x509 -new -key ca.key -sha256 -days 3650 \
-subj "/O=Example Internal/CN=Example Internal Root CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash" \
-addext "nameConstraints=critical,permitted;DNS:internal.example" \
-out ca.crtReplace internal.example with the name suffix you actually use, and read the next section before you keep that last extension.
Each extension does one job.
basicConstraintswithCA:TRUEis what makes this a CA certificate. Without it, a client rejects any certificate this key signs, even when the signature itself is correct.pathlen:0says the CA may sign leaf certificates and no further CAs below it.keyUsagerestricts the key to signing certificates and revocation lists, so the same key cannot be used as a TLS server key by mistake.subjectKeyIdentifiergives the root an identifier that leaf certificates point back at, which is how a client finds the right issuer inside a store holding a few hundred of them.nameConstraintslimits the names this CA is allowed to vouch for.
Read back what you made instead of assuming the command did what you meant.
openssl x509 -noout -subject -issuer -serial -dates -in ca.crt
openssl x509 -noout -text -in ca.crtSubject and issuer print the same string, because a root certificate signs itself. The serial and the two dates come from the file you just created, so take them from that output rather than from anyone's guide.
Limit what your CA is allowed to sign
A root in the system store is trusted for every name on the internet unless you say otherwise. That is a large amount of authority to hold in one file on one server. nameConstraints reduces it. With permitted;DNS:internal.example in the root, a chain from this CA for a name outside internal.example is rejected even though the signature is good.
Test that rather than trusting it.
openssl req -new -newkey rsa:2048 -nodes -keyout /tmp/outside.key -out /tmp/outside.csr \
-subj "/CN=www.example.com"
openssl x509 -req -in /tmp/outside.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 30 -sha256 \
-extfile <(printf 'subjectAltName=DNS:www.example.com\n') -out /tmp/outside.crt
openssl verify -CAfile ca.crt /tmp/outside.crt
echo $?The certificate is issued, because your CA signs whatever you ask it to sign. Verification is where it dies: the exit status is non-zero and OpenSSL names the constraint it hit. That is the value of the extension. A stolen CA key still cannot produce a working certificate for a name outside the subtree. Delete the leftovers with rm /tmp/outside.* when you are done.
Four things to know before you commit to a constraint. It is marked critical, so a client that does not understand the extension must reject the chain rather than ignore it, which is the safe direction but can surprise an old TLS library. A permitted subtree for DNS names does not restrict IP address SANs, because a name type with no subtree listed stays unrestricted, so add permitted;IP:10.0.0.0/255.255.0.0 in the same extension if your certificates carry IP addresses. The subtree must cover every name you will ever issue, including short hostnames, so a certificate for the bare name app would fail against the example above. And the constraint is baked into the root, so changing your mind means a new root certificate and a fresh install on every client.
Issue a leaf certificate signed by your CA
A leaf certificate is the one a server presents to clients. Start with its own key and a CSR (certificate signing request), which carries the public key and the requested name, signed by the leaf key to prove the requester holds the private half.
openssl req -new -newkey rsa:2048 -nodes \
-keyout app.key -out app.csr \
-subj "/CN=app.internal.example"
chmod 600 app.keyThe names that matter go in an extension file, not in the CSR. Clients match the hostname against subjectAltName (SAN) and ignore the common name completely, so a certificate with a CN and no SAN fails hostname verification on every current client, whatever the CN says.
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:app.internal.example, DNS:api.internal.example
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:alwaysSave that as app.ext, then sign the request with the CA.
openssl x509 -req -in app.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 397 -sha256 -extfile app.ext -out app.crt-CAcreateserial writes ca.srl next to the CA, holding the next serial number so no two certificates from this CA share one. Keep that file in the CA directory. -days 397 is a choice, not a limit of the tool. Short lifetimes matter more here than with a public CA, because a private CA has no revocation infrastructure: there is no CRL and no OCSP responder unless you build one, so a leaked leaf key stays usable until the certificate expires.
Check the result before you go near the trust store.
openssl x509 -noout -subject -issuer -serial -dates -in app.crt
openssl x509 -noout -ext subjectAltName -in app.crtThe issuer line now names the CA instead of the leaf itself. The SAN line lists the names this certificate is valid for, and a client matches against that list and nothing else.
Verify with an explicit -CAfile, before installing anything
openssl verify -CAfile ca.crt app.crt
echo $?This asks one narrow question: does app.crt chain to the certificate in ca.crt? It says nothing about what this machine trusts, because you handed OpenSSL the root on the command line. A failure here is a problem with the certificates themselves, so fix it before continuing.
Now ask the machine.
openssl verify app.crt
echo $?With no -CAfile, OpenSSL falls back to its built-in certificate directory. openssl version -d prints the base directory your build uses, and on Ubuntu the certs directory under it resolves to /etc/ssl/certs. Your root is not there yet, so verification fails: the chain reaches an issuer the store does not hold, and there is nowhere left to look. Note the exit status. It is the thing that changes two steps from now.
A real client makes a better test than openssl verify, because it checks the hostname as well as the chain. Serve the certificate and fetch it.
openssl s_server -accept 8443 -cert app.crt -key app.key -www &
curl --resolve app.internal.example:8443:127.0.0.1 https://app.internal.example:8443/--resolve sends the connection to 127.0.0.1 while still requesting app.internal.example, so the SAN matches and the only open question is trust. curl fails and prints the reason it could not verify the chain. Add -v for more detail. Leave the test server running.
Install the root into /usr/local/share/ca-certificates
sudo cp ca.crt /usr/local/share/ca-certificates/example-internal-root.crt
sudo chmod 644 /usr/local/share/ca-certificates/example-internal-root.crt
sudo update-ca-certificatesThe details that decide whether this works at all:
- The filename must end in
.crt. Theupdate-ca-certificatesmanual page states that certificates with a.crtextension found below/usr/local/share/ca-certificatesare included and implicitly trusted. A file namedroot.pemorroot.ceris skipped without a word about it. - The content must be PEM, which is the base64 block wrapped in
BEGIN CERTIFICATEandEND CERTIFICATElines. A DER file renamed to.crtis still binary and is not read. Convert it withopenssl x509 -inform DER -in ca.der -out ca.crt. - Only the root belongs here. The CA private key and the leaf certificate have no business in a trust store.
update-ca-certificates prints how many certificates it added and removed. If it added none, the extension or the file format is why.
Confirm the change from the system's side rather than from that message.
ls -l /etc/ssl/certs/$(openssl x509 -noout -subject_hash -in /usr/local/share/ca-certificates/example-internal-root.crt).0
grep -c 'BEGIN CERTIFICATE' /etc/ssl/certs/ca-certificates.crtThe first command builds a filename out of your certificate's own subject hash and lists it. update-ca-certificates created that symlink, and it points back at the file you installed. The second counts the certificates in the single-file bundle. Run it before the install as well and you can watch the number move by one.
When you copy this root to other machines, check that the copy arrived intact before installing it. A root certificate is the worst file on the system to get wrong, so treat it like any other download you would verify with a checksum before use.
Verify again against the system store
openssl verify app.crt
echo $?
curl --resolve app.internal.example:8443:127.0.0.1 https://app.internal.example:8443/Same commands, same certificate files, different answer. Nothing about app.crt changed, and the server is the one you started earlier. The only difference is that the root now sits in the store those clients read, so the chain completes. That is the mechanism worth remembering: verification is a search for an issuer the client already trusts, and installing a CA is how the issuer gets into the place it searches.
Stop the test server with kill %1.
Why /etc/ssl/certs is not where you put your file
/etc/ssl/certs is generated output. update-ca-certificates fills it with symlinks back to the real certificate files and writes the concatenated bundle /etc/ssl/certs/ca-certificates.crt beside them.
A certificate you copy into that directory by hand is found by nothing. OpenSSL's directory lookup only opens files named after a certificate's subject hash, so a file called myca.crt is invisible to it. curl on Ubuntu reads the bundle file, and the bundle is rebuilt from the registered sources, so your copy is not in that path either. Run update-ca-certificates --fresh and the symlinks in the directory are removed and rebuilt, which takes any hand-made link with them.
The other half of the split is /usr/share/ca-certificates, which belongs to the ca-certificates package and is listed in /etc/ca-certificates.conf. Package updates rewrite it. /usr/local/share/ca-certificates is the directory reserved for the local administrator, so your CA survives every upgrade of the package that manages the rest.
Which programs ignore the system trust store
Installing the root fixes every program that asks OpenSSL or reads /etc/ssl/certs. That covers curl, wget, git, Python's standard ssl module, and Go programs, which read the system files on Linux. Runtimes shipping their own certificate list are unaffected, and that is where most of the confusion after a successful install comes from.
- Node.js uses a compiled-in list. Point it at your root with
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/example-internal-root.crt, set in the environment before the process starts, because Node reads the variable once at startup. Current Node releases also have an option to read the system store; runnode --help | grep -i system-cato see whether your version has it. - Python's
requestslibrary uses thecertifibundle. SetREQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crtfor that process, or passverify="/etc/ssl/certs/ca-certificates.crt"to the call.piptakes--certfor the same reason. - Java reads a keystore. On Ubuntu the
ca-certificates-javapackage installs a hook under/etc/ca-certificates/update.d/, soupdate-ca-certificatesrefreshes the Java keystore too when that package is present. Without it, import the root withkeytool -importcert. - Firefox keeps its own store and never looks at
/etc/ssl/certs. Import through its certificate settings. Chromium on Linux reads a per-user NSS database, which you edit withcertutilfrom thelibnss3-toolspackage. - Containers have their own filesystem, so the host's store means nothing inside them. Copy the root into the image and run
update-ca-certificatesduring the build. Plan for that if your services run under Docker Compose on a VPS.
When a program still refuses the certificate after a clean install, find out which files it opens before changing anything else. strace -f -e trace=openat <command> 2>&1 | grep -i cert is blunt, and it answers the question in one run.
Keeping the CA usable over time
Reissuing a leaf is the CSR step and the signing step again, with the same app.ext file. Clients need no action, because the root they trust has not changed. Keep ca.srl and every .ext file in the CA directory so the next issue is a repeat of a command that worked, not a reconstruction from memory.
Back up ca.key and ca.crt somewhere off the machine, still encrypted. Lose the key and you cannot issue anything new: you have to build a second CA and install its root everywhere the first one went. Keep a written list of every machine and every application store that received the root, because that list is what makes rotation and removal possible at all.
When the root itself approaches expiry, generate the replacement early and install both roots side by side. Two roots in the store is fine, and a client accepts either. Reissue the leaves against the new root, then remove the old one once nothing depends on it.
Remove a CA from the trust store
sudo rm /usr/local/share/ca-certificates/example-internal-root.crt
sudo update-ca-certificates --fresh--fresh removes the symlinks in /etc/ssl/certs and rebuilds them from the sources that are still present, so the deleted root leaves the directory and the bundle together. Prove the removal the same way you proved the install.
openssl verify app.crt
echo $?
grep -c 'BEGIN CERTIFICATE' /etc/ssl/certs/ca-certificates.crt
ls -l /etc/ssl/certs/$(openssl x509 -noout -subject_hash -in ~/ca/ca.crt).0Verification fails again, the certificate count returns to where it started, and the hash symlink is gone.
That command touches the system store and nothing else. Undo the install in each of the other places by hand: clear NODE_EXTRA_CA_CERTS, delete the alias from any Java keystore, remove the root from each browser profile, and rebuild any container image that baked it in. Removing the root also does not invalidate the certificates it signed. They stay valid on every machine that still trusts it, which is the practical reason a private CA needs that written list of where the root went. A CA you cannot withdraw completely is a permanent hole, so test the removal on one machine on the day you set it up, while the list is still short.
FAQ
Where do I put a CA certificate on Ubuntu?
In /usr/local/share/ca-certificates/, with a filename ending in .crt and PEM content, then run sudo update-ca-certificates. That directory is reserved for the local administrator, so package upgrades leave it alone. /usr/share/ca-certificates belongs to the ca-certificates package, and /etc/ssl/certs is generated from both, so a file placed in either of those is overwritten or ignored.
Why does curl still reject the certificate after update-ca-certificates?
Work through the causes in order. The file may not end in .crt, or may be DER rather than PEM, in which case update-ca-certificates skipped it and added nothing. The certificate may have no subjectAltName matching the hostname, which is a hostname failure rather than a trust failure; check with openssl x509 -noout -ext subjectAltName -in app.crt. The server may be sending only the leaf when an intermediate is also needed. curl may be pointed at a different bundle by CURL_CA_BUNDLE or --cacert. And a long-running service needs a restart, because most programs read the trust store once when they start.
Does the system trust store cover Firefox, Chrome, Node and Java?
No. curl, wget, git, Python's standard ssl module and Go programs read the system files, so those work as soon as update-ca-certificates runs. Firefox keeps its own store. Chromium on Linux uses a per-user NSS database, edited with certutil from the libnss3-tools package. Node.js needs NODE_EXTRA_CA_CERTS pointing at your root file. Java reads a keystore, which update-ca-certificates refreshes only when the ca-certificates-java package is installed. Python's requests uses certifi and needs REQUESTS_CA_BUNDLE.
How do I remove a CA from Ubuntu's trust store?
Delete the file from /usr/local/share/ca-certificates/ and run sudo update-ca-certificates --fresh. The --fresh option clears the symlinks in /etc/ssl/certs and rebuilds them, so the certificate leaves the hash symlinks and the ca-certificates.crt bundle at the same time. Confirm by running openssl verify against a certificate that CA signed and reading the exit status. Then repeat the removal in every other store you added it to, because that command does not touch any of them.
Can I use a private CA instead of Let's Encrypt for a public site?
No. A visitor's browser has never seen your root, so it shows a full-page warning, and you cannot install your root on machines you do not control. A private CA is for names that only your own machines resolve and for clients you administer. For anything a stranger visits, get the certificate from a public CA.