SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor

Ubuntu self-signed certificate wey Chrome go trust

Create self-signed TLS cert for Ubuntu 24.04 with SAN, nginx or Apache setup, and proper client trust, so you no need click warnings or use curl -k.

Wetin you dey build

A self-signed TLS certificate wey modern browsers and clients go actually accept, correct subjectAltName, proper key permissions, and setup inside nginx or Apache. E also include the part wey almost every guide dey skip: make your clients trust am properly, instead of clicking through warnings and hard-coding curl -k inside scripts forever. For the end, you go get a five-command private CA for when one internal service become six.

First, make the decision, because self-signed certificate no be the correct tool as often as people dey use am. If service dey reachable from public internet under a real DNS name, stop reading and get free Let's Encrypt certificate with certbot for nginx or the Apache equivalent instead. E cost nothing, renew itself, and mainstream browsers and operating systems already trust am. Self-signed certificate for public site dey train users to click through security warnings. That habit worse pass plain HTTP.

Self-signed na the correct tool when public internet no dey involved: admin panel wey dey bound to WireGuard tunnel address for your VPS, staging box for private network, service-to-service traffic between backends, home-lab appliance, or replacing the placeholder certificate wey Webmin dey generate for itself on port 10000. Let's Encrypt no fit issue certificate for 10.8.0.1 or git.internal.lan anyway. No public CA go put private IP or made-up TLD inside certificate. For those names, na you be the CA.

Everything below dey run on fresh Ubuntu 24.04 box, wey ships OpenSSL 3.0.x (openssl version to confirm). Nothing here need internet access; everything dey work air-gapped.

Why the old one-liner dey produce certs wey Chrome dey reject

The command wey every pre-2017 tutorial dey give you look 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

E dey ask series of interactive questions, put your hostname for the Common Name field, then produce certificate wey no get subjectAltName extension. That certificate don fail before e even start. Chrome stop dey read Common Name for version 58, back for April 2017. RFC 2818 don already deprecate CN matching for year 2000, and Firefox plus Safari dey behave the same way. curl and Python's ssl module still dey fall back to Common Name when certificate no get SAN at all. Na why certificate without SAN fit pass quick curl test but still fail for every browser. For browser, certificate dey identify its server through SAN extension, or e no identify am at all. Browser go tell you this with 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 adjustment fit fix that error, because the certificate truly no name any server. If you dey look at NET::ERR_CERT_COMMON_NAME_INVALID right now, your certificate no get SAN (or e get wrong one), and you need mint a new one. Luckily, one command go fix am.

Mint certificate wey browsers go accept: one command

OpenSSL add the -addext flag for version 1.1.1. So you no longer need the config-file gymnastics wey old guides use to inject SAN. For 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"

Wetin each flag dey do:

  • -x509 dey generate self-signed certificate directly instead of signing request.
  • -newkey rsa:4096 dey generate fresh key for the same step. RSA 4096 no go cause problem for any old client. If everything wey dey connect na modern, -newkey ec -pkeyopt ec_paramgen_curve:P-256 smaller and faster.
  • -noenc na the OpenSSL 3.x spelling of the old -nodes: e mean say key no get passphrase. Both spellings dey work. Key wey get passphrase go make nginx hang as e dey wait for input every time system boot. So for server key, na this one you want.
  • -days 730 mean two years. We go explain that number more for expiry section.
  • -subj dey answer the interactive questions inline. CN don become mostly cosmetic now, but still set am to the primary name; some tools dey display am.
  • -addext "subjectAltName=..." na the important flag. List every name and every IP wey clients go type: DNS: entries for hostnames (wildcards like DNS:*.internal.lan dey okay), IP: entries for addresses. If anybody go browse to https://10.8.0.1, the IP:10.8.0.1 entry must dey there. DNS-only SAN go give dem NET::ERR_CERT_COMMON_NAME_INVALID all over again.

Confirm say SAN really enter the certificate before you connect anything:

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 e print No extensions in certificate instead, the certificate no get SAN and browsers go reject am. Generate am again instead of continuing.

Lock down the key

Private key wey every user for the box fit read no be private key. For normal Ubuntu 24.04 box, /etc/ssl/private dey already 700 root:root, so na only root fit even list am; e go become 710 root:ssl-cert only if something don bring in ssl-cert package (apache2 recommends am). Anyhow, set the file itself clearly:

sudo chown root:root /etc/ssl/private/git.internal.key
sudo chmod 600 /etc/ssl/private/git.internal.key

nginx and Apache both dey read certificates as root before dem drop privileges, so root:root mode 600 dey work for dem. If na service wey dey run with im own user and dey load the key by itself, like Node app, Gitea, or Python daemon, chown am to that service user instead, still with mode 600. Wetin you no ever do: mode 644, copy inside git repository, or copy inside /tmp.

Connect am to 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 reload fit do anything. If e print SSL_CTX_use_PrivateKey_file() failed ... key values mismatch instead, the certificate and key come from two different generation runs; see the failure-modes section.

Connect am Apache

sudo a2enmod ssl proxy proxy_http

ssl by itself no reach here: the vhost below dey use ProxyPass, and without mod_proxy and mod_proxy_http, config test go fail 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 suppose answer Syntax OK. Now test am from client machine:

curl -v https://git.internal.lan/

and you go get error:

curl: (60) SSL certificate problem: self-signed certificate

This no be bug. Na TLS dey work: curl never hear about your certificate, so e refuse to talk to server wey e no fit authenticate. The next section na the real fix, and e no be wetin half of the internet dey do for this exact moment.

Make clients trust am, and the wrong fixes wey you suppose reject

Make we start with the wrong fixes and call dem by their real names. curl -k (or --insecure) wey you put inside script, verify=False for Python requests, and NODE_TLS_REJECT_UNAUTHORIZED=0 for Node no dey make your certificate trusted. Dem dey switch certificate verification off. This mean say client go gladly talk to any server wey present any certificate, including certificate wey attacker put for the path. You still carry the TLS overhead, but you lose the authentication wey TLS suppose provide. Worse still, these flags dey spread: person paste am for one cron job, then deploy script, then production code, until nobody remember which connections suppose temporary. If verify=False remain after the debugging session wey create am don finish, the design no correct.

The correct fix na to teach each client OS say this certificate na trusted root. For Ubuntu and Debian clients:

sudo cp git.internal.crt /usr/local/share/ca-certificates/git.internal.crt
sudo update-ca-certificates

The important line for the output na this one (a Running hooks in /etc/ca-certificates/update.d... block go follow am):

Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.

Two common problems dey hide inside these lines. The file must end with .crt. If you use .pem extension, system go silently ignore am, and you go get 0 added without any error message. The content must also be PEM, and the file must start with -----BEGIN CERTIFICATE-----. If the certificate na DER binary, first convert am 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 na its own root.

After that, curl, wget, git, apt, and any other tool wey uses OpenSSL against the system bundle go trust the server without any flag. Some clients get their own trust stores, so dem need separate configuration:

  • Chrome/Chromium for Linux dey read NSS database, not the system store: sudo apt install libnss3-tools, then certutil -d sql:$HOME/.local/share/pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crt for each user. This path na Chrome default since version 146. If user still get legacy ~/.pki/nssdb from older installation, Chrome go continue to read that one instead. For those machines, point -d to sql:$HOME/.pki/nssdb.
  • Firefox get its own store: Settings → Privacy & Security → Certificates → Import. For Windows and macOS, you fit instead set security.enterprise_roots.enabled to true inside about:config so e go read the OS store. Mozilla document this pref for Windows and macOS, not Linux, so for Ubuntu use the import option.
  • Python requests dey ship with its own CA bundle (certifi) and e dey ignore the system store: pass verify="/usr/local/share/ca-certificates/git.internal.crt" or export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt.
  • Node.js dey use its own bundled roots by default: export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt, or make am read the system store wey you just update with the --use-system-ca flag (Node 23.9 and later for Linux) or NODE_USE_SYSTEM_CA=1 (Node 24.6 and 22.19 and later).

For Windows clients, double-click .crt and install am inside Trusted Root Certification Authorities. For macOS, add am to the System keychain through Keychain Access, then mark am Always Trust.

One root for many services: small private CA

Per-certificate trust no dey scale at all: six services times four client machines na twenty-four trust installations, and every new service go add more. The solution na private CA. Clients trust one root, then you sign every service certificate with am.

The easy option na mkcert. E dey Ubuntu 24.04 repos and e handle the NSS stores for Firefox and Chrome, with one exception wey update-ca-certificates miss:

sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1

mkcert -install dey create one root and register am for the system store, browser NSS stores wey e fit find, and Java store when JAVA_HOME dey set. Node no dey read the system store unless you tell am to, so start Node apps with --use-system-ca (or NODE_USE_SYSTEM_CA=1) like before, or export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem". The third command dey produce git.internal.lan+2.pem and git.internal.lan+2-key.pem, ready to put inside the nginx or Apache snippets above. E assume say na development machine you dey use. The root key dey on whichever box run -install, so e perfect for dev laptop but e no suit server fleet.

The problem as of October 2026 be say mkcert only dey look for Chrome NSS database for the old ~/.pki/nssdb path, not ~/.local/share/pki/nssdb, wey Chrome dey use by default since version 146. For machine wey no get the old database, add the root to Chrome yourself with the certutil command from the previous section, using -i "$(mkcert -CAROOT)/rootCA.pem".

For servers, plain OpenSSL fit do the complete CA work with 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 dey for the last command: openssl x509 -req dey remove all extensions from the CSR by default, including the SAN wey you carefully add. -copy_extensions copy (an OpenSSL 3.x option, so e dey work for 24.04) carry dem across. If you leave am out, the signed certificate no go get SAN, and Chrome go greet you with NET::ERR_CERT_COMMON_NAME_INVALID again. Verify am with the same openssl x509 -noout -ext subjectAltName check wey you use before.

Distribute lab-ca.crt to clients through the trust-store steps above, once for each machine, forever. Protect lab-ca.key well: set mode 600, and if possible keep am for a box wey no be one of the servers e sign for, because anybody wey hold am fit create certificate for any name your clients go trust.

Expiry and rotation

Public CA certificate lifetime dey reduce fast. CA/Browser Forum limit newly issued publicly trusted certificates to 200 days for March 2026, down from 398. E go reduce to 100 days for 2027 and 47 days by March 2029. But these rules apply to publicly trusted CAs. Dem no apply to your private CA, and browsers no enforce dem against roots wey you install manually. One real-world limit still apply: Apple platforms reject any TLS server certificate wey valid pass 825 days, no matter who issue am. So if iPhones or Macs go connect, keep leaf certificates to two years or less. -days 730 pass this limit everywhere. One ten-year root with two-year leaves na comfortable internal setup.

Long-lived certificates fit fail for only one way: dem go expire silently, all at once, on date wey nobody remember say dem choose. Check wetin you get:

openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddate

Put renewal for real calendar, or make cron remind you 30 days before expiry. openssl x509 -checkend 2592000 -in cert.crt go exit with non-zero status once expiry dey within that number of seconds. If you already dey run Uptime Kuma for status monitoring, its HTTPS monitors go flag certificate expiry wey dey near for free.

Rotation with private CA dey straightforward: run the CSR-and-sign commands again, replace the files, then reload the web server. The root no change, so no client go notice anything.

Failure modes, with the strings you go see

NET::ERR_CERT_AUTHORITY_INVALID, na the expected state before you install trust; e no mean say certificate get problem. If e continue after you install the root: for Linux, Chrome dey read NSS instead of the system store (see the certutil step), and since Chrome 146 that database dey for ~/.local/share/pki/nssdb unless legacy ~/.pki/nssdb dey exist, so root wey you add to the other path no go ever show; or the copied file no end with .crt and update-ca-certificates talk say 0 added; or server dey present different certificate from the one wey you trust, 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, certificate no get SAN, or the SAN no cover the name wey dey for address bar. The common case be say SAN list DNS:git.internal.lan but user browse go https://10.8.0.1. Trust-store changes no fit fix this one; issue certificate again with the missing entry.

curl: (60) SSL certificate problem: self-signed certificate, curl no trust the certificate. The self-signed certificate in certificate chain variant mean the same thing for certificate wey your private CA sign. One-time fix: curl --cacert lab-ca.crt https://...; permanent fix: the trust store. No be -k.

unable to load certificate ... Expecting: TRUSTED CERTIFICATE (or Expecting: CERTIFICATE REQUEST, or no start line), na PEM confusion. You give OpenSSL wrong kind file: key or CSR where e expect certificate, or DER binary where e expect PEM. head -1 filename tell you wetin you actually get; certificate dey start with -----BEGIN CERTIFICATE-----. For DER, convert am with openssl x509 -inform der -in file.der -out file.crt.

nginx: [emerg] SSL_CTX_use_PrivateKey_file(...) failed (SSL: error ... key values mismatch), certificate and key no belong together, usually because generation command run twice and files mix up. Confirm with openssl x509 -in git.internal.crt -noout -pubkey | sha256sum versus openssl pkey -in git.internal.key -pubout | sha256sum; if hashes match, the pair match. If dem differ, generate both again together.

FAQ

Why Chrome still dey talk say "Not secure" after I create self-signed certificate?

If the error na NET::ERR_CERT_AUTHORITY_INVALID, the certificate dey okay; Chrome just never get reason to trust am yet. Install am (or your private CA root) for the client's trust store. Remember say for Linux, Chrome dey use the NSS database through certutil (for ~/.local/share/pki/nssdb since Chrome 146, or legacy ~/.pki/nssdb if one dey), not the system store. If the error na NET::ERR_CERT_COMMON_NAME_INVALID, the certificate no get Subject Alternative Name wey match the URL, so you must issue am again with -addext "subjectAltName=...".

How I fit make curl trust self-signed certificate without -k?

Copy the certificate (PEM format, .crt extension) enter /usr/local/share/ca-certificates/ and run sudo update-ca-certificates. The output must talk say 1 added. From that point, curl go verify am like any public certificate. For one request without touching the system, curl --cacert /path/to/cert.crt go verify against only that file. -k dey disable verification completely, so e no belong inside anybody's scripts.

How long self-signed certificate fit remain valid?

Technically, e fit remain valid as long as you want. The CA/Browser Forum limits (200 days now, 47 by 2029) apply to publicly trusted CAs, not private trust. For practice, limit server certificates to 825 days, because Apple devices go reject anything longer, no matter who issue am. A ten-year private root with two-year (-days 730) leaf certificates na sensible default. Just put the renewal for calendar, because expired internal cert fit bring everything down quietly on a date wey nobody remember.

I suppose use self-signed certificate or Let's Encrypt?

If the service get public DNS name and internet fit reach am, always use Let's Encrypt. E free, automated, and most browsers and operating systems already trust am. Check Let's Encrypt's compatibility list if very old devices must connect. Self-signed (or private CA) na for things wey Let's Encrypt no fit issue: private IPs, internal-only hostnames like .lan, air-gapped networks, and services wey you deliberately hide behind VPN. The decision dey depend on reachability and naming, not security strength. The cryptography na the same.

Why certificate still dey rejected after I add am to /usr/local/share/ca-certificates?

Check three things. The file must end with .crt. A .pem extension go skip silently, and update-ca-certificates go report 0 added. The contents must be PEM text wey start with -----BEGIN CERTIFICATE-----, not DER binary. Also confirm say the application actually dey use the system store. Chrome on Linux, Firefox, Python requests, Node.js (unless you start am with --use-system-ca), and Java each keep private trust store, so you must add the certificate separately for each one.