Self-signed certificates on Ubuntu, done right
Mint a self-signed TLS cert Chrome actually accepts on Ubuntu 24.04: one openssl command with SAN, nginx/Apache wiring, and trusting it without curl -k.
What you are building
A self-signed TLS certificate that modern browsers and clients actually accept — correct subjectAltName, sane key permissions, wired into nginx or Apache — plus the piece almost every guide skips: making your clients trust it properly, instead of clicking through warnings and hard-coding curl -k into scripts forever. At the end, a five-command private CA for when one internal service becomes six.
First, the decision, because a self-signed certificate is the right tool far less often than it gets used. If the service is reachable from the public internet under a real DNS name, stop reading and get a free Let's Encrypt certificate with certbot on nginx or the Apache equivalent instead. It costs nothing, renews itself, and every browser on earth already trusts it. A self-signed certificate on a public site trains your users to click through security warnings, which is a worse habit than plain HTTP.
Self-signed is the right tool when there is no public internet in the picture: an admin panel bound to a WireGuard tunnel address on your VPS, a staging box on a private network, service-to-service traffic between backends, a home-lab appliance, or replacing the placeholder certificate that Webmin generates for itself on port 10000. Let's Encrypt cannot issue for 10.8.0.1 or git.internal.lan anyway — no public CA will put a private IP or a made-up TLD in a certificate. For those names, you are the CA.
Everything below runs on a fresh Ubuntu 24.04 box, which ships OpenSSL 3.0.x (openssl version to confirm). Nothing here needs internet access; it all works air-gapped.
Why the old one-liner produces certs Chrome rejects
The command every pre-2017 tutorial hands you looks like this:
# Do not run this — shown so you recognise it in old guides
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout selfsigned.key -out selfsigned.crt
It asks a series of interactive questions, puts your hostname in the Common Name field, and produces a certificate with no subjectAltName extension. That certificate is dead on arrival. Chrome stopped reading the Common Name in version 58, back in April 2017 — RFC 2818 had already deprecated CN matching in the year 2000 — and Firefox, Safari, curl, and Python behave the same way. A certificate identifies its server through the SAN extension or not at all, and the browser tells you so in exactly these words:
NET::ERR_CERT_COMMON_NAME_INVALID
This server could not prove that it is git.internal.lan; its security
certificate does not specify Subject Alternative Names.
No amount of trust-store fiddling fixes that error, because the certificate genuinely names nothing. If you are staring at NET::ERR_CERT_COMMON_NAME_INVALID right now, your certificate has no SAN (or the wrong one) and you need to mint a new one. Fortunately the fix is one command.
Mint a certificate browsers accept: one command
OpenSSL grew the -addext flag in 1.1.1, which means you no longer need the config-file gymnastics old guides used to inject a SAN. On Ubuntu 24.04:
sudo openssl req -x509 -newkey rsa:4096 -sha256 -days 730 -noenc \
-keyout /etc/ssl/private/git.internal.key \
-out /etc/ssl/certs/git.internal.crt \
-subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"
What each flag is doing:
-x509emits a self-signed certificate directly instead of a signing request.-newkey rsa:4096generates a fresh key in the same step. RSA 4096 offends no legacy client; if everything connecting is modern,-newkey ec -pkeyopt ec_paramgen_curve:P-256is smaller and faster.-noencis the OpenSSL 3.x spelling of the old-nodes: no passphrase on the key. Both spellings work. A passphrased key means nginx hangs waiting for input at every boot, so for a server key you want this.-days 730— two years; more on that number in the expiry section.-subjanswers the interactive questions inline. The CN is cosmetic now, but set it to the primary name anyway; some tools display it.-addext "subjectAltName=..."is the load-bearing flag. List every name and every IP clients will type:DNS:entries for hostnames (wildcards likeDNS:*.internal.lanare fine),IP:entries for addresses. If anyone will browse tohttps://10.8.0.1, theIP:10.8.0.1entry must be there — a DNS-only SAN gives themNET::ERR_CERT_COMMON_NAME_INVALIDall over again.
Confirm the SAN actually landed before wiring anything up:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -ext subjectAltName
Correct output:
X509v3 Subject Alternative Name:
DNS:git.internal.lan, IP Address:10.8.0.1
If that instead prints No extensions in certificate, the certificate has no SAN and browsers will reject it — regenerate rather than pressing on.
Lock down the key
A private key readable by every user on the box is not a private key. On Ubuntu, /etc/ssl/private is already 710 root:ssl-cert, which keeps casual eyes out, but set the file itself explicitly:
sudo chown root:root /etc/ssl/private/git.internal.key
sudo chmod 600 /etc/ssl/private/git.internal.key
nginx and Apache both read certificates as root before dropping privileges, so root:root mode 600 works for them. If the key is for a service that runs as its own user and loads the key itself — a Node app, Gitea, a Python daemon — chown it to that service user instead, still mode 600. What you never do: mode 644, a copy in a git repository, or a copy in /tmp.
Wire it into nginx
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name git.internal.lan;
ssl_certificate /etc/ssl/certs/git.internal.crt;
ssl_certificate_key /etc/ssl/private/git.internal.key;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
sudo nginx -t && sudo systemctl reload nginx
nginx -t must print syntax is ok and test is successful before the reload does anything. If it instead prints SSL_CTX_use_PrivateKey_file() failed ... key values mismatch, the certificate and key are from two different generation runs — see the failure-modes section.
Wire it into Apache
sudo a2enmod ssl proxy proxy_http
ssl alone is not enough here: the vhost below uses ProxyPass, and without mod_proxy and mod_proxy_http the config test dies with Invalid command 'ProxyPass', perhaps misspelled or defined by a module not included in the server configuration. Save the vhost as /etc/apache2/sites-available/git-internal.conf:
<VirtualHost *:443>
ServerName git.internal.lan
SSLEngine on
SSLCertificateFile /etc/ssl/certs/git.internal.crt
SSLCertificateKeyFile /etc/ssl/private/git.internal.key
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>
sudo a2ensite git-internal
sudo apache2ctl configtest && sudo systemctl reload apache2
configtest should answer Syntax OK. Now test from a client machine:
curl -v https://git.internal.lan/
and you will get an error:
curl: (60) SSL certificate problem: self-signed certificate
That is not a bug. That is TLS working: curl has never heard of your certificate and refuses to talk to a server it cannot authenticate. The next section is the real fix — and it is not what half the internet does at this exact moment.
Make clients trust it — and the anti-patterns to refuse
The wrong fixes first, named as what they are. curl -k (or --insecure) baked into a script, verify=False in Python requests, NODE_TLS_REJECT_UNAUTHORIZED=0 in Node — none of these make your certificate trusted. They switch certificate verification off, which means the client will happily talk to any server presenting any certificate, including one an attacker put in the path. You keep the overhead of TLS and lose the authentication that was its point. Worse, these flags metastasize: pasted into one cron job, then a deploy script, then production code, until nobody remembers which connections were supposed to be temporary. If a verify=False survives past the debugging session that spawned it, the design is wrong.
The right fix is to teach each client OS that this certificate is a trusted root. On Ubuntu and Debian clients:
sudo cp git.internal.crt /usr/local/share/ca-certificates/git.internal.crt
sudo update-ca-certificates
The line that matters in the output (a Running hooks in /etc/ca-certificates/update.d... block follows it):
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.
Two gotchas hide in those lines. The file must end in .crt — a .pem extension is silently ignored and you get 0 added with no error message. And the contents must be PEM — the file starts with -----BEGIN CERTIFICATE-----; convert a DER binary first with openssl x509 -inform der -in file.der -out file.crt. Adding the self-signed certificate itself as a root works because a self-signed certificate is its own root.
After that, curl, wget, git, apt, and anything else that uses OpenSSL against the system bundle trusts the server with no flags at all. A handful of clients carry their own trust stores and need individual treatment:
- Chrome/Chromium on Linux reads an NSS database, not the system store:
sudo apt install libnss3-tools, thencertutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crtper user. - Firefox has its own store: Settings → Privacy & Security → Certificates → Import, or flip
security.enterprise_roots.enabledtotrueinabout:configso it reads the system store. - Python requests ships its own CA bundle (certifi) and ignores the system store: pass
verify="/usr/local/share/ca-certificates/git.internal.crt"or exportREQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt. - Node.js: export
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt.
On Windows clients, double-click the .crt and install it to Trusted Root Certification Authorities; on macOS, add it to the System keychain in Keychain Access and mark it Always Trust.
One root for many services: a small private CA
Per-certificate trust stops scaling immediately: six services times four client machines is twenty-four trust installs, and every new service adds more. The fix is a private CA — clients trust one root, and you sign each service's certificate with it.
The friendly option is mkcert, which is in the Ubuntu 24.04 repos and handles the NSS stores (Chrome, Firefox) that update-ca-certificates misses:
sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1
mkcert -install creates a root and registers it in every trust store on that machine; the third command emits git.internal.lan+2.pem and git.internal.lan+2-key.pem, ready to drop into the nginx or Apache snippets above. Its design assumption is a development machine — the root key lives on whatever box ran -install — so it is perfect for a dev laptop and the wrong shape for a server fleet.
For servers, plain OpenSSL does the whole CA in five commands:
openssl genrsa -out lab-ca.key 4096
openssl req -x509 -new -key lab-ca.key -sha256 -days 3650 \
-out lab-ca.crt -subj "/CN=Lab Internal CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genrsa -out git.key 2048
openssl req -new -key git.key -out git.csr -subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"
openssl x509 -req -in git.csr -CA lab-ca.crt -CAkey lab-ca.key \
-CAcreateserial -days 730 -sha256 -copy_extensions copy -out git.crt
The trap is in the last command: openssl x509 -req drops all extensions from the CSR by default, including the SAN you carefully added. -copy_extensions copy (an OpenSSL 3.x option, so it works on 24.04) carries them across; omit it and the signed certificate has no SAN, and Chrome greets you with NET::ERR_CERT_COMMON_NAME_INVALID again. Verify with the same openssl x509 -noout -ext subjectAltName check as before.
Distribute lab-ca.crt to clients via the trust-store steps above — once per machine, ever. Guard lab-ca.key like the crown jewels it now is: mode 600, ideally kept on a box that is not one of the servers it signs for, because whoever holds it can mint a certificate for any name your clients will believe.
Expiry and rotation
Public CA certificate lifetimes are collapsing — the CA/Browser Forum capped newly issued publicly trusted certificates at 200 days in March 2026 (down from 398), stepping to 100 days in 2027 and 47 days by March 2029 — but those rules bind publicly trusted CAs. Your private CA is not governed by them, and browsers do not enforce them against manually installed roots. One real-world clamp does apply: Apple platforms reject any TLS server certificate valid longer than 825 days no matter who issued it, so if iPhones or Macs will connect, keep leaf certificates at two years or less. -days 730 clears that bar everywhere; a ten-year root with two-year leaves is a comfortable internal shape.
Long-lived certificates fail in exactly one way: silently, all at once, on a date nobody remembers choosing. Check what you have:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddate
Put the renewal in an actual calendar, or have cron nag you 30 days out — openssl x509 -checkend 2592000 -in cert.crt exits non-zero once expiry is within that many seconds. If you already run Uptime Kuma for status monitoring, its HTTPS monitors flag approaching certificate expiry for free.
Rotation with a private CA is pleasantly boring: re-run the CSR-and-sign commands, swap the files, reload the web server. The root has not changed, so no client notices anything.
Failure modes, with the strings you will see
NET::ERR_CERT_AUTHORITY_INVALID — the expected state before you install trust, not a defect in the certificate. If it persists after you installed the root: on Linux, Chrome reads NSS rather than the system store (see the certutil step); or the copied file did not end in .crt and update-ca-certificates said 0 added; or the server is presenting a different certificate than the one you trusted — compare fingerprints with openssl s_client -connect git.internal.lan:443 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256.
NET::ERR_CERT_COMMON_NAME_INVALID — the certificate has no SAN, or the SAN does not cover the name in the address bar. The classic case: the SAN lists DNS:git.internal.lan but the user browsed to https://10.8.0.1. Trust-store changes cannot fix this one; reissue with the missing entry.
curl: (60) SSL certificate problem: self-signed certificate — curl does not trust the certificate. The variant self-signed certificate in certificate chain means the same thing for a cert signed by your private CA. One-off fix: curl --cacert lab-ca.crt https://...; permanent fix: the trust store. Not -k.
unable to load certificate ... Expecting: TRUSTED CERTIFICATE (or Expecting: CERTIFICATE REQUEST, or no start line) — PEM confusion. You fed OpenSSL the wrong kind of file: a key or CSR where it expected a certificate, or a DER binary where it expected PEM. head -1 filename tells you what you actually have — a certificate starts with -----BEGIN CERTIFICATE-----. For DER, convert with openssl x509 -inform der -in file.der -out file.crt.
nginx: [emerg] SSL_CTX_use_PrivateKey_file(...) failed (SSL: error ... key values mismatch) — the certificate and key do not belong together, usually because the generation command was run twice and the files got mixed. Confirm with openssl x509 -in git.internal.crt -noout -pubkey | sha256sum versus openssl pkey -in git.internal.key -pubout | sha256sum; matching hashes mean a matching pair. If they differ, regenerate both together.
FAQ
Why does Chrome still say "Not secure" after I created a self-signed certificate?
If the error is NET::ERR_CERT_AUTHORITY_INVALID, the certificate is fine — Chrome simply has no reason to trust it yet. Install it (or your private CA root) into the client's trust store, and remember that on Linux, Chrome uses the NSS database via certutil, not the system store. If the error is NET::ERR_CERT_COMMON_NAME_INVALID, the certificate lacks a Subject Alternative Name matching the URL and must be reissued with -addext "subjectAltName=...".
How do I make curl trust a self-signed certificate without -k?
Copy the certificate (PEM format, .crt extension) into /usr/local/share/ca-certificates/ and run sudo update-ca-certificates — the output must say 1 added. From then on curl verifies it like any public certificate. For a one-off request without touching the system, curl --cacert /path/to/cert.crt verifies against that file alone; -k disables verification entirely and belongs in nobody's scripts.
How long can a self-signed certificate be valid?
Technically as long as you like — the CA/Browser Forum limits (200 days now, 47 by 2029) bind publicly trusted CAs, not private trust. In practice, cap server certificates at 825 days, because Apple devices reject anything longer regardless of issuer. A ten-year private root with two-year (-days 730) leaf certificates is a sane default; just calendar the renewal, because an expired internal cert takes everything down silently on a date nobody remembers.
Should I use a self-signed certificate or Let's Encrypt?
If the service has a public DNS name and is reachable from the internet, always Let's Encrypt — free, automated, already trusted by every client. Self-signed (or a private CA) is for what Let's Encrypt cannot issue: private IPs, internal-only hostnames like .lan, air-gapped networks, and services deliberately hidden behind a VPN. The decision is about reachability and naming, not security strength — the cryptography is identical.
Why is my certificate rejected even after adding it to /usr/local/share/ca-certificates?
Check three things. The file must end in .crt — a .pem extension is silently skipped and update-ca-certificates reports 0 added. The contents must be PEM text beginning -----BEGIN CERTIFICATE-----, not DER binary. And the application must actually use the system store — Chrome on Linux, Firefox, Python requests, Node.js, and Java each keep a private trust store and need the certificate added separately.