SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-07

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, connected to nginx or Apache, plus 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 don become six.

First, make the decision, because self-signed certificate no be the correct tool most times wey people dey use am. If service dey reachable from public internet under real DNS name, stop reading and get free Let's Encrypt certificate with certbot for nginx or the Apache equivalent instead. E no cost anything, e renew itself, and every browser for the world 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 replacement for the placeholder certificate wey Webmin generates 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 for 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 go 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, and produce certificate wey no get subjectAltName extension. That certificate don fail before e even start. Chrome stop to read Common Name for version 58, back for April 2017. RFC 2818 don already deprecate CN matching for year 2000. Firefox, Safari, curl, and Python dey behave the same way. Certificate dey identify im server through SAN extension, or e no identify am at all. Browser go tell you so 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 really no name any server. If you dey look NET::ERR_CERT_COMMON_NAME_INVALID now, your certificate no get SAN (or na wrong one) and you need create new one. Luckily, na 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.

Key lock waka

Private key wey every user for the server fit read no be private key again. For Ubuntu, /etc/ssl/private already dey 710 root:ssl-cert, wey dey stop casual eyes, but set permission for the file by yourself:

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 go that service user instead, but still use mode 600. Wetin you no ever do be: 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 bad patterns wey you no suppose use

Make we first name the wrong fixes as dem really be. curl -k (or --insecure) wey you put inside script, verify=False for Python requests, and NODE_TLS_REJECT_UNAUTHORIZED=0 for Node no go make clients trust your certificate. Dem dey switch certificate verification off. This means client go gladly talk to any server wey present any certificate, including certificate wey attacker put for the path. You still carry 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 deployment script, then production code, until nobody remember which connections suppose temporary. If verify=False remain after the debugging session wey cause am, the design wrong.

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 (a Running hooks in /etc/ca-certificates/update.d... block 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 error message. The contents must also be PEM. The file go start with -----BEGIN CERTIFICATE-----. If na DER binary, first convert am with openssl x509 -inform der -in file.der -out file.crt. Adding the self-signed certificate itself as root dey work because self-signed certificate na its own root.

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

  • Chrome/Chromium for Linux dey read NSS database, not the system store: sudo apt install libnss3-tools, then certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crt for each user.
  • Firefox get its own store: Settings → Privacy & Security → Certificates → Import, or change security.enterprise_roots.enabled to true inside about:config so e go read the system store.
  • Python requests dey ship its own CA bundle (certifi) and 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: export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt.

For Windows clients, double-click .crt and install am for Trusted Root Certification Authorities. For macOS, add am to the System keychain inside Keychain Access and 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 installs, and every new service go add more. The solution na private CA. Clients trust one root, and you sign each service certificate with am.

The easy option na mkcert. E dey inside Ubuntu 24.04 repos and e handle the NSS stores (Chrome, Firefox) wey update-ca-certificates no handle:

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

mkcert -install dey create root and register am for every trust store on that machine. The third command dey generate git.internal.lan+2.pem and git.internal.lan+2-key.pem, ready to put inside the nginx or Apache snippets above. The design assume say na development machine. The root key dey for any box wey run -install. So e perfect for dev laptop, but e no fit server fleet.

For servers, plain OpenSSL fit do the complete CA 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 problem dey inside the last command: openssl x509 -req dey remove all extensions from the CSR by default, including the SAN wey you add carefully. -copy_extensions copy (an OpenSSL 3.x option, so e work for 24.04) dey carry dem across. If you omit am, the signed certificate no 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, ever. Protect lab-ca.key well because e don become very important: set mode 600, and preferably keep am for box wey no be one of the servers wey e sign for. Anybody wey hold am fit mint certificate for any name wey 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, and the strings wey you go see

NET::ERR_CERT_AUTHORITY_INVALID, na the state wey you expect 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 system store (see the certutil step); or the file wey you copy no end with .crt and update-ca-certificates show 0 added; or server dey present another certificate different from the one 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 SAN no cover the name wey dey address bar. The common case be say SAN list DNS:git.internal.lan but user browse to https://10.8.0.1. Trust-store changes no fit fix this one; issue the 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. Temporary fix na curl --cacert lab-ca.crt https://...; permanent fix na 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 the wrong kind file: a key or CSR where e expect certificate, or a 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 different, generate both again together.

FAQ

Why Chrome still dey show "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, inside the client trust store. Remember say for Linux, Chrome dey use NSS database through certutil, no be 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, for PEM format and with .crt extension, go /usr/local/share/ca-certificates/. Then run sudo update-ca-certificates. The output must talk say 1 added. From that time, curl go verify am like any public certificate. For one request without touching the system, curl --cacert /path/to/cert.crt go verify against that file alone. -k disables verification completely, so e no suppose dey inside anybody scripts.

How long self-signed certificate fit remain valid?

Technically, e fit remain valid for as long as you like. The CA/Browser Forum limits, 200 days now and 47 by 2029, apply to publicly trusted CAs, no be private trust. For normal use, limit server certificates to 825 days, because Apple devices go reject any one wey pass that period, no matter who issue am. Private root wey last ten years with two-year (-days 730) leaf certificates na sensible default. Just put the renewal for calendar, because expired internal cert fit bring everything down silently on a date 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 every client already trust am. 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 depend on reachability and naming, no be security strength. The cryptography na the same.

Why my 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 get skipped silently, and update-ca-certificates go report 0 added. The content must be PEM text wey start with -----BEGIN CERTIFICATE-----, no be DER binary. Also, the application must actually use the system store. Chrome on Linux, Firefox, Python requests, Node.js, and Java each get private trust store, so you need add the certificate separately for each one.