For internet-facing domains, Let’s Encrypt is the right answer. But the moment you have a homelab service running on a .local domain, on a private subnet, or behind Tailscale — Let’s Encrypt either doesn’t work (HTTP-01 needs public reachability) or is awkward (DNS-01 needs API credentials for a public zone you may not own). Browsers complain, curl complains, every script you write needs --insecure.
The fix is your own certificate authority. It’s 30 lines of openssl to set up, takes one minute to run, and once installed in each device’s trust store, every cert it signs is genuinely valid in browsers, in curl, in language libraries, in Docker — anywhere.
The 30 lines
#!/bin/bash
set -euo pipefail
mkdir -p ~/homelab-ca && cd ~/homelab-ca
# 1. Root CA private key (4096-bit RSA, 10 years validity)
openssl genrsa -out ca.key 4096
# 2. Self-signed root CA cert
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-subj "/CN=Homelab Root CA/O=Homelab" \
-out ca.crt
# 3. Per-service: generate a key + CSR
SERVICE="dashboard.homelab.local"
openssl genrsa -out "$SERVICE.key" 2048
cat > "$SERVICE.cnf" <<EOF
[req]
distinguished_name = req
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = $SERVICE
DNS.2 = dashboard
IP.1 = 192.168.1.20
EOF
openssl req -new -key "$SERVICE.key" \
-subj "/CN=$SERVICE" \
-out "$SERVICE.csr"
# 4. Sign with root CA
openssl x509 -req -in "$SERVICE.csr" \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out "$SERVICE.crt" -days 825 -sha256 \
-extfile "$SERVICE.cnf" -extensions v3_req
# 5. Verify
openssl verify -CAfile ca.crt "$SERVICE.crt"
echo "Cert ready: $SERVICE.crt + $SERVICE.key"
That’s it. ca.crt is your root cert (install this in trust stores, distribute publicly). ca.key is the signing key (lock down, never share). $SERVICE.crt + $SERVICE.key are what your nginx/caddy/lsws/whatever serves.
The 825-day life is intentional
Browsers (Chrome and Safari particularly) reject any cert with a validity longer than 825 days, even from a trusted private CA. The constant catches people self-signing for “5 years.” Stick to -days 825 for the leaf cert; the root CA itself can be 10 years (or longer) because root cert validity is exempt from this restriction.
If 825 days feels short, automate the renewal. The same script can re-run from step 3 onwards — every 2 years on a calendar reminder is less painful than rebuilding the entire chain.
SubjectAltName matters more than CN
Modern TLS clients ignore the Common Name and look at SubjectAltName for hostname matching. The block in $SERVICE.cnf with DNS.1, DNS.2, IP.1 is what makes the cert valid for both the FQDN, the short name, and the IP. List every name you’ll connect with — including Tailscale hostnames if applicable (dashboard.your-tailnet.ts.net).
Common mistake: serving on https://192.168.1.20 with a cert that has only CN=dashboard.homelab.local. Browsers warn; curl errors. Adding IP.1 = 192.168.1.20 to the SAN block fixes it.
Trust-store install per OS
The point of all this is making ca.crt trusted on the devices you’ll use. Every OS has its own trust store; here are the four that cover most setups:
- Ubuntu/Debian:
sudo cp ca.crt /usr/local/share/ca-certificates/homelab-ca.crt && sudo update-ca-certificates. Affects every system tool that uses the OS trust store: curl, wget, apt, Python’srequestswhenverify=True, Node’sfetch. - macOS:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca.crt. Affects Safari, curl, and every other tool that uses the macOS keychain. Chrome on macOS also uses this. Firefox does not — it has its own store. - Firefox (any OS): Settings → Privacy & Security → View Certificates → Authorities → Import. Tick “Trust this CA to identify websites.” Firefox stubbornly maintains its own store and ignores OS-level trust.
- iOS / iPadOS: Email
ca.crtto yourself, tap on it from the Mail app, install via Settings → General → VPN & Device Management. Then also turn on full trust: Settings → General → About → Certificate Trust Settings → toggle on for “Homelab Root CA.” This second step is what people miss.
Android and Windows have their own quirks (and Android even has separate user vs system trust stores), but the per-distro install command for the device’s main trust store is what matters. Once installed, you don’t have to think about it again.
The “and now you also have a cert renewal problem” footnote
The downside of running your own CA: when leaf certs expire in 825 days, your homelab quietly breaks. Set a calendar reminder for “renew homelab certs” 60 days before expiry. The script above is reusable — just re-run from step 3 with a fresh $SERVICE.crt, restart your reverse proxy, and you’re back.
If you want zero-touch renewal, look at step-ca from Smallstep — it’s a turnkey private CA with ACME support, so your homelab services can renew the same way they would against Let’s Encrypt. Higher complexity than the openssl approach; same end result.
Why this is worth the effort
Browsers stop warning. curl works without -k. Docker pulls work against your private registry. Python’s requests works without verify=False — which means scripts that hit your homelab don’t have to disable TLS validation entirely just to talk to it.
The first time you run an https://dashboard.homelab.local in a browser and see the green padlock without any warnings, the 30 lines of openssl are vindicated.
Cover photo: Markus Winkler on Pexels.
