How To

Create Locally Trusted SSL Certificates with mkcert

mkcert gives you local SSL certificates that browsers, curl and most tools trust without a warning. It creates a small certificate authority on your machine, adds it to the system and browser trust stores, and signs certificates for whatever names you give it, such as app.test, localhost or 127.0.0.1. That beats clicking through a self-signed warning on every reload, and it lets you test HTTPS-only features (secure cookies, service workers, HTTP/2) on a laptop.

Original content from computingforgeeks.com - post 82916

One thing to know first: mkcert’s last release came out in April 2022 and the project has been quiet since, but the distro packages still work and nothing in its job has changed. We installed it on Ubuntu 26.04 and 24.04, Debian 13, Fedora 45 and Rocky Linux 10 in October 2026, served a certificate through Nginx, and checked which Python and Node.js setups trust it and which don’t.

What mkcert changes on your machine

mkcert keeps its CA in one folder, the CAROOT, and copies only the public certificate into the places that decide what your machine trusts. On Linux that means:

  • ~/.local/share/mkcert/rootCA.pem and rootCA-key.pem
  • the system store (/usr/local/share/ca-certificates/ on Ubuntu)
  • the NSS databases of Firefox and Chrome, if certutil is installed

The CA is per user and per machine, so a certificate from your laptop is trusted on your laptop and nowhere else. That is the point: it is a development tool, and a staging server that strangers visit needs a real certificate from Let’s Encrypt instead.

Install mkcert on Linux

Every distro we tested ships the same mkcert release, so the choice comes down to whether your package manager has it. Install certutil alongside it (the NSS tools package), because without it mkcert skips Firefox and Chrome and only updates the system store:

DistroSourcePackage we got
Ubuntu 26.04universe1.4.4-1ubuntu2
Ubuntu 24.04universe1.4.4-1ubuntu3.2
Debian 13main1.4.4-1+b18
Fedora 45fedora1.4.4-10.fc45
Rocky Linux 10not in BaseOS, AppStream or EPELupstream binary v1.4.4

Ubuntu and Debian

Both packages sit in the default repositories, mkcert in universe on Ubuntu and in main on Debian, so there is no PPA or extra source to add. Install them together so the browser stores get updated on the very first run:

sudo apt update
sudo apt install -y mkcert libnss3-tools

Ubuntu 22.04 still ships the older 1.4.3 build; we tested only the 1.4.4 packages in the table above.

Fedora

Fedora carries mkcert in its main repository, so it installs and updates with the rest of the system. The nss-tools package provides certutil, which mkcert needs for Firefox and Chrome:

sudo dnf install -y mkcert nss-tools

Do not be thrown by mkcert -version printing (devel) on Fedora. The package is built from source without the version stamp the upstream binaries carry, so the output says nothing about how current it is. Run rpm -q mkcert when you need the real release, which was 1.4.4 on Fedora 45.

Rocky Linux, AlmaLinux and RHEL

Neither the base repositories nor EPEL carry mkcert for Rocky Linux 10, so use the upstream binary. The download link always points at the latest release, which keeps this command correct if a new version ever ships:

sudo dnf install -y nss-tools
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert

Check that the binary runs:

mkcert -version

It prints the release it downloaded:

v1.4.4

If the shell says the command is not found instead, /usr/local/bin is missing from your PATH, which happens on some minimal images. Run export PATH=$PATH:/usr/local/bin (and add it to ~/.bashrc), or call /usr/local/bin/mkcert directly.

SELinux stayed in enforcing mode the whole time and needed no changes. mkcert writes to your home folder. Through sudo it also writes to /etc/pki/ca-trust and the shared NSS database in /etc/pki/nssdb, all with their normal labels. On an ARM machine, replace amd64 with arm64 on all three lines, because the downloaded file is named after the architecture. Arch Linux has mkcert in its official repository (sudo pacman -Syu mkcert nss), which we did not test here.

Install mkcert on macOS and Windows

The mkcert README lists Homebrew on macOS (brew install mkcert, plus brew install nss if you use Firefox) and Chocolatey or Scoop on Windows (choco install mkcert, or scoop bucket add extras then scoop install mkcert). The mkcert commands in the rest of this guide are the same on those systems, and mkcert updates the macOS keychain or the Windows certificate store instead of the Linux files. The surrounding shell commands are not: on Windows the hosts file is C:\Windows\System32\drivers\etc\hosts, and sudo, tee and the Nginx paths do not apply. We ran the Linux steps only.

Create the local CA

Run the install step once per user, without sudo. mkcert asks for your password itself when it writes to the system store, and running the whole thing as root puts the CA in root’s home and the browser trust in root’s profile, which your browser never reads:

mkcert -install

On a fresh Ubuntu 26.04 machine, the first run created the CA and installed it system-wide:

Created a new local CA 💥
The local CA is now installed in the system trust store! ⚡️

There’s no browser line in that output because the machine had no browser at all. On a desktop where Firefox is installed but has never been opened, mkcert instead prints ERROR: no Firefox and/or Chrome/Chromium security databases found, because the profile it writes into does not exist yet. Open each browser once before running mkcert -install. After we installed the Firefox snap and opened it, a second run found the profile under ~/snap/firefox/common/.mozilla/firefox/ and added the CA there, so the snap Firefox on Ubuntu needs nothing special beyond a browser restart. Run mkcert -install again whenever you add a browser.

The CA lasts ten years from the day you create it, and mkcert -CAROOT tells you where it lives:

openssl x509 -in "$(mkcert -CAROOT)/rootCA.pem" -noout -subject -dates

The subject names the user and host that made it, which helps when you find a stray mkcert CA on a machine later:

subject=O=mkcert development CA, OU=josphat@dev01 (Ubuntu), CN=mkcert josphat@dev01 (Ubuntu)
notBefore=Oct 17 12:12:25 2026 GMT
notAfter=Oct 17 12:12:25 2036 GMT

Ten years is far longer than any leaf certificate it signs, so you will rarely need to recreate the CA itself.

Generate a locally trusted certificate

List every name the certificate should cover in one command, because a certificate cannot be extended later. Use -cert-file and -key-file to get readable file names; without them mkcert names the files after the first host plus a count, such as app.test+4.pem:

mkdir -p ~/certs && cd ~/certs
mkcert -cert-file app.test.crt -key-file app.test.key app.test "*.app.test" localhost 127.0.0.1 ::1

Leaf certificates are valid for 27 months (ours expires on 17 January 2029), which stays under the 825-day limit Apple enforces for TLS certificates. Prefer the .test top-level domain for names: it is reserved for testing and never resolves on the internet, unlike .dev, which is a real TLD whose browsers force HTTPS everywhere. Add -ecdsa to the command if you want a P-256 key instead of RSA.

curl: (60) SSL: no alternative certificate subject name matches target hostname ‘a.b.app.test’

A wildcard covers exactly one label, so *.app.test matches api.app.test but not a.b.app.test, and mkcert even prints a reminder about it. Browsers apply the same rule, so the error is not a curl quirk you can ignore. Issue a second certificate that lists *.b.app.test, or name the deep host explicitly, because one certificate can hold as many names as you like.

Use the certificate with Nginx

Any server that takes a PEM certificate and key works the same way; we used Nginx because it’s the most common case for local work. Install it if the machine doesn’t have it yet. On Ubuntu and Debian:

sudo apt install -y nginx

On Fedora, Rocky Linux, AlmaLinux and RHEL, the package does not start on its own, so enable it as well:

sudo dnf install -y nginx
sudo systemctl enable --now nginx

Point the test names at your own machine, since .test names don’t exist in public DNS:

echo "127.0.0.1 app.test api.app.test" | sudo tee -a /etc/hosts

Copy the pair where Nginx can read it. The copies end up owned by root with the key still at mode 600, which is fine, because the Nginx master process starts as root and reads the key before it drops privileges:

sudo mkdir -p /etc/nginx/certs
sudo cp ~/certs/app.test.crt ~/certs/app.test.key /etc/nginx/certs/

Create a server block for the site. On Ubuntu and Debian it goes under sites-available, so one symlink switches the test site on or off and the default site stays untouched:

sudo vim /etc/nginx/sites-available/app.test

The RHEL family has no sites-available folder, and nginx.conf there loads every file in conf.d, so create the file in that folder instead:

sudo vim /etc/nginx/conf.d/app.test.conf

Minimal cloud images sometimes ship only vi, so use any editor you have, or install vim first. The same minimal block works in either file. It answers on 443 for the main name and any one-level subdomain:

server {
    listen 443 ssl;
    server_name app.test *.app.test;
    ssl_certificate     /etc/nginx/certs/app.test.crt;
    ssl_certificate_key /etc/nginx/certs/app.test.key;
    location / {
        return 200 "hello from $host over TLS\n";
    }
}

On Ubuntu and Debian, enable the site with a symlink (the RHEL family skips this, since conf.d is already loaded):

sudo ln -s /etc/nginx/sites-available/app.test /etc/nginx/sites-enabled/

Test the syntax before reloading on any distro. A wrong certificate path fails the test, and if you skip it the reload is refused while Nginx keeps serving the old configuration, which looks like nothing changed:

sudo nginx -t && sudo systemctl reload nginx

Now request both names without -k. If either prints a certificate error, the CA is not in the store curl reads:

curl https://app.test/
curl https://api.app.test/

Both answered over a verified connection, which proves two things at once: the CA is in the store curl reads, and the wildcard covers the subdomain:

hello from app.test over TLS
hello from api.app.test over TLS

We got the same two lines on Rocky Linux 10 with SELinux enforcing and no AVC denials, because files copied into /etc/nginx/certs take the httpd_config_t label from the folder.

The whole flow on our Ubuntu 26.04 machine, captured after Firefox was added (so mkcert -install shows the browser line) and ending with the request against Nginx:

mkcert -install adding the local CA to the system and Firefox trust stores on Ubuntu 26.04, then issuing a certificate for app.test and a wildcard, and curl reaching https://app.test without a warning

For a real stack on a server, the Nginx and Let’s Encrypt guide covers the production side. If you’d rather not manage certificate files at all on a dev box, Caddy runs its own internal CA for local names.

Make Python and Node.js trust the CA

Browsers and curl trust the CA right away, but language runtimes that bring their own CA bundle ignore the system store. So the same script can work with one Python and fail with another on the same machine, depending on where the runtime came from. This is what we saw on Ubuntu 26.04:

ClientTrusts mkcert?Why
Python requests from aptYesDebian’s certifi package points at the system bundle
requests from pip in a venvNoUses the certifi bundle
Node.js 22 from aptYesBuilt against the system store
Node.js 24 from nodejs.orgNoUses its bundled Mozilla CAs

SSLError: certificate verify failed: unable to get local issuer certificate

This is what requests from pip raised against https://app.test, because the certifi package inside the venv has never heard of your CA and does not read the system store. Point it at mkcert’s root for the session, or add the export to the virtualenv’s activate script:

export REQUESTS_CA_BUNDLE="$(mkcert -CAROOT)/rootCA.pem"

That variable replaces the bundle rather than adding to it, so requests to public sites from the same shell fail. Our test against github.com raised the same SSLError. If your code talks to both, point the variable at /etc/ssl/certs/ca-certificates.crt on Ubuntu or Debian, or /etc/pki/tls/certs/ca-bundle.crt on the RHEL family, instead. That file already includes the mkcert CA alongside the public roots, because mkcert -install regenerated it. On Ubuntu, both requests returned 200 with that bundle.

Node.js: UNABLE_TO_VERIFY_LEAF_SIGNATURE

The official Node.js 24 build failed with that code until it was told about the CA. Two fixes worked, and the first one adds to the bundled CAs instead of replacing them:

export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"

The alternative is to start Node with node --use-system-ca app.js, which reads the system store that mkcert -install already updated. That flag is the cleaner choice on a dev box, because new roots you trust later work without touching the environment, but on Linux it needs Node 22.15 or newer. If Node comes from the Ubuntu repository, the first method in the Node.js on Ubuntu guide, neither is needed. NodeSource, nvm and fnm install upstream builds that behave like the nodejs.org row in the table.

Trust the certificate on another device

A phone or a second laptop that opens your dev site sees an untrusted certificate, because the CA lives only on the machine that ran mkcert -install. The certificate also has to name something the other device can reach. On a phone, 127.0.0.1 is the phone itself and app.test does not resolve. Reissue the certificate with the LAN IP or hostname of your dev machine added, copy the pair to Nginx again and reload it, and make that name resolve on the device. Keep the file names the Nginx block expects:

mkcert -cert-file app.test.crt -key-file app.test.key app.test "*.app.test" 192.168.1.50

Replace 192.168.1.50 with the address of your dev machine.

Then copy rootCA.pem from mkcert -CAROOT to the device and import it as a trusted root. On iOS, install the profile and then enable full trust under Settings, General, About, Certificate Trust Settings. On Android, user-installed roots are trusted by Chrome (Firefox needs its third-party CA setting turned on) but not by apps unless the app opts in, so this mainly helps browser testing. Copy the .pem file only, never rootCA-key.pem, and remove the CA from the device when you’re done testing. We did not run this part on a phone.

Remove the mkcert CA

Uninstalling takes the CA out of every trust store it was added to, but leaves the files in CAROOT:

mkcert -uninstall

Right after it, the same curl that worked a minute earlier failed. The certificate on Nginx did not change. The system bundle simply no longer contains the CA that signed it:

The local CA is now uninstalled from the system trust store(s)! 👋

curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)

Because the files stay put, mkcert -install brings the same CA back and every certificate you issued works again. To get rid of it for good, uninstall first and then delete the folder that mkcert -CAROOT prints.

Protect the root CA key

rootCA-key.pem is the one file that matters. Anyone who has it can mint a certificate for any name, including your bank’s, and your machine will trust it, which is why mkcert creates it read-only for your user (-r--------). Keep it out of dotfile repositories and backups you sync to shared storage, don’t bake it into Docker images, and never use mkcert certificates on anything other people connect to. A shared staging environment is the job for a real internal CA, such as the FreeIPA ACME setup, where certificates can be revoked and the key isn’t sitting in one developer’s home folder.

Keep reading

UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Php PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Php PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Best GPUs for Hashcat: Password-Cracking Benchmarks by Card Kali Linux Best GPUs for Hashcat: Password-Cracking Benchmarks by Card Best Phone for GrapheneOS in 2026 (and Why to Skip the Pixel 11) Security Best Phone for GrapheneOS in 2026 (and Why to Skip the Pixel 11) Is ZimaOS Safe? Harden Remote Access and Ports on ZimaBoard 2 Security Is ZimaOS Safe? Harden Remote Access and Ports on ZimaBoard 2

Leave a Comment

Press ESC to close