How to Run a Local Certificate Authority (Real Padlocks for Internal Names with mkcert and step-ca)

Let's Encrypt (tutorial 27) needs a public domain. Inside the house you've got nas.home.lan, pihole.lan, Cockpit on an IP — all with browser warnings, and some things (service workers, HomeKit, Vaultwarden's apps) simply refuse plain HTTP. A local certificate authority fixes it: you create one root certificate, install it once on each device, and every certificate it issues shows a real padlock on the LAN. mkcert does the quick version in five minutes; step-ca does the proper version with automatic renewal.
Quick reference
| Tool | Best for | Renewal |
|---|---|---|
| mkcert | a handful of internal names, set up once | manual (certificates last ~2 years) |
| step-ca | many services, Caddy/Certbot/Traefik renewing by ACME | automatic (24-hour certs) |
| Caddy tls internal | the lazy middle: Caddy runs its own CA (tutorial 57) | automatic |
| Wildcard via DNS challenge | real Let's Encrypt for internal names under a public domain (tutorial 147) | automatic |
Option 4 is often the best answer if you own a domain; this tutorial is for names with no public domain, and for understanding what the others do.
Part A: mkcert
1. Create the CA and a certificate
On the server:
sudo apt install libnss3-tools -y
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64 && sudo mv mkcert-v*-linux-amd64 /usr/local/bin/mkcert
mkcert -install
mkcert -cert-file /etc/ssl/local/home.lan.pem -key-file /etc/ssl/local/home.lan-key.pem "*.home.lan" home.lan 192.168.1.50
(sudo mkdir -p /etc/ssl/local first.) -install creates the root in ~/.local/share/mkcert/ and trusts it on this machine. The second command issues a wildcard for *.home.lan plus the server's IP — one certificate for every internal service.
2. Use it in Nginx or Caddy
Nginx server block for an internal name:
listen 443 ssl;
server_name nas.home.lan;
ssl_certificate /etc/ssl/local/home.lan.pem;
ssl_certificate_key /etc/ssl/local/home.lan-key.pem;
Caddy: nas.home.lan { tls /etc/ssl/local/home.lan.pem /etc/ssl/local/home.lan-key.pem reverse_proxy ... }. Make sure Pi-hole (34) resolves *.home.lan to the server.
3. Trust the root on every device
Copy rootCA.pem from ~/.local/share/mkcert/ (never the -key file) to each device:
- Windows: double-click → Install Certificate → Local Machine → Trusted Root Certification Authorities. Firefox needs it imported separately under Settings → Certificates (or set security.enterprise_roots.enabled).
- Mac: double-click → Keychain Access → System → trust Always.
- iPhone/iPad: AirDrop or email the file → Settings → Profile Downloaded → Install → then Settings → General → About → Certificate Trust Settings → enable full trust.
- Android: Settings → Security → Encryption & credentials → Install a certificate → CA certificate. Chrome respects it; some apps don't.
- Linux: sudo cp rootCA.pem /usr/local/share/ca-certificates/home-ca.crt && sudo update-ca-certificates.
Open https://nas.home.lan: padlock. Repeat for every device once; new certificates from the same CA are trusted automatically.
Keep rootCA-key.pem safe (it's a chmod 600 file in your home folder) — anyone with it can impersonate any site to your devices. Back it up encrypted; don't put it on the server's shared folders.
Part B: step-ca (automatic renewal)
mkcert certificates last two years and you reissue by hand. step-ca is a real CA server that speaks ACME, so Caddy, Certbot and Traefik renew from it exactly as they do from Let's Encrypt.
mkdir -p ~/docker/step-ca && cd ~/docker/step-ca
nano compose.yaml
services:
step-ca:
image: smallstep/step-ca:latest
container_name: step-ca
ports:
- "9000:9000"
environment:
DOCKER_STEPCA_INIT_NAME: Home CA
DOCKER_STEPCA_INIT_DNS_NAMES: ca.home.lan,192.168.1.50
DOCKER_STEPCA_INIT_PROVISIONER_NAME: admin
DOCKER_STEPCA_INIT_PASSWORD: ${CA_PASSWORD}
volumes:
- ./data:/home/step
restart: unless-stopped
docker compose up -d
docker compose logs step-ca | grep -i fingerprint # note it
docker compose exec step-ca step ca provisioner add acme --type ACME
docker compose restart step-ca
The root certificate is ./data/certs/root_ca.crt — install it on devices exactly as in Part A, step 3.
Then point your ACME clients at it. Caddy, globally:
{
acme_ca https://ca.home.lan:9000/acme/acme/directory
acme_ca_root /etc/ssl/local/root_ca.crt
}
nas.home.lan { reverse_proxy 127.0.0.1:9443 }
Certbot: sudo certbot certonly --standalone -d nas.home.lan --server https://ca.home.lan:9000/acme/acme/directory, with REQUESTS_CA_BUNDLE=/etc/ssl/local/root_ca.crt in the environment. Certificates last 24 hours and renew themselves; nothing to remember.
Troubleshooting
Still "not private" on one device. The root isn't trusted there (iOS needs the second step under Certificate Trust Settings; Firefox has its own store).
Android app ignores the CA. Apps can opt out of user-installed CAs; nothing to do but use a public name with a real certificate (tutorial 147).
step-ca ACME fails: "unable to verify". The client doesn't trust step-ca's root — set acme_ca_root / REQUESTS_CA_BUNDLE.
Lost the CA key. Make a new CA and re-trust it everywhere; the old certificates keep working until expiry.
Wrapping up
mkcert for a wildcard on *.home.lan in five minutes and the root on each device once; step-ca when you want ACME renewal inside the house. The padlock stops lying on the LAN, the picky apps start working, and the only secret to guard is one root key file.
