Internal TLS with a private CA¶
LAN-only names like <SERVICE>.<YOUR_LOCAL_DOMAIN> can't get a Let's Encrypt certificate: a private suffix like home.arpa
isn't a public domain, so no public CA will validate it. Instead of clicking through browser warnings, I
run a small private root CA made with mkcert, issue one wildcard
certificate from it, and load that into NPM. Every device that trusts the root CA then gets a clean padlock on every
LAN name.
browser ─► AdGuard Home: *.<YOUR_LOCAL_DOMAIN> → NPM's LAN IP
─► NPM :443 (wildcard cert signed by your root CA)
─► service (plain HTTP on the LAN / Docker network)
The public names (<SERVICE>.<YOUR_DOMAIN>) keep their Let's Encrypt wildcard; see
Tunnel + NPM. Both live in the same NPM instance.
Put services on their own sub-zone
X.509 wildcards cover exactly one label, and many browsers refuse a wildcard right under a single-label name
(*.lan). Use a sub-zone for services, e.g. <YOUR_LOCAL_DOMAIN> = lab.home.arpa, so the cert is
*.lab.home.arpa. It also keeps service names apart from the DHCP host names (server1.home.arpa), so the
wildcard DNS rewrite doesn't shadow them.
1. Create the root CA¶
Do this on a trusted machine (your workstation), not on a server. On Debian 13:
sudo apt install mkcert libnss3-tools # libnss3-tools lets mkcert add the CA to Firefox/Chromium
mkcert -install # creates the CA and trusts it on this machine
mkcert -CAROOT # prints where rootCA.pem and rootCA-key.pem live
rootCA.pem is the public certificate you hand to clients. rootCA-key.pem can sign a certificate for any name
and every device that trusts the CA will believe it.
Guard the CA key
Keep rootCA-key.pem only on the workstation and in encrypted backups. Never copy it to a server, a container or
a git repo. If it leaks, remove the CA from every device and start over.
2. Issue the wildcard certificate¶
mkcert "*.<YOUR_LOCAL_DOMAIN>"
# → _wildcard.<YOUR_LOCAL_DOMAIN>.pem (certificate)
# → _wildcard.<YOUR_LOCAL_DOMAIN>-key.pem (private key)
mkcert certificates are valid for 2 years and 3 months; the root CA for 10 years. Put the leaf expiry date in your calendar now (step 6).
3. Load it into NPM¶
- SSL Certificates → Add SSL Certificate → Custom Certificate
- Name it (e.g.
local wildcard), upload the-key.pemas Certificate Key and the.pemas Certificate. You can leave Intermediate Certificate empty (mkcert signs leaves straight from the root). -
For each service, add a proxy host:
Field Value Domain Names <SERVICE>.<YOUR_LOCAL_DOMAIN>Scheme / Forward Hostname / Port as for any proxy host SSL Certificate local wildcardForce SSL ✅ enabled HTTP/2 Support ✅ enabled
After saving, check that the host really answers on 443 with the new cert:
curl -sI https://<SERVICE>.<YOUR_LOCAL_DOMAIN> | head -1
openssl s_client -connect <YOUR_SERVER_IP>:443 -servername <SERVICE>.<YOUR_LOCAL_DOMAIN> </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -enddate
4. Point the names at NPM¶
One wildcard rewrite in AdGuard Home covers every service: AdGuard Home → DNS rewrites. New service = new NPM proxy host, nothing else.
5. Trust the CA on clients¶
| Client | How |
|---|---|
| Your workstation | mkcert -install already did it |
| Other Debian machines (system store) | sudo cp rootCA.pem /usr/local/share/ca-certificates/homelab-rootCA.crt && sudo update-ca-certificates (the file must end in .crt) |
| Firefox elsewhere | Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import (or the Certificates enterprise policy) |
| Windows | Double-click the .crt → Install Certificate → Local Machine → Trusted Root Certification Authorities |
| Android | Settings → Security → Encryption & credentials → Install a certificate → CA certificate. Browsers honour it; many apps ignore user-installed CAs |
| iOS / iPadOS | AirDrop or mail the .pem, install the profile, then Settings → General → About → Certificate Trust Settings → enable full trust |
6. Trust the CA inside containers¶
Browsers aren't the only clients. When one container calls another over https://<SERVICE>.<YOUR_LOCAL_DOMAIN>
(a git server's webhook to a deploy tool, a monitoring agent to its hub), it fails with
x509: certificate signed by unknown authority unless that container trusts your CA too. Each runtime has its own
knob:
| Runtime | How to add the CA |
|---|---|
Debian/Ubuntu-based images that run update-ca-certificates |
Mount the cert into /usr/local/share/ca-certificates/ |
| Go programs (many self-hosted apps) | SSL_CERT_FILE pointing at a bundle that contains the system CAs plus yours |
| Node.js | NODE_EXTRA_CA_CERTS=/path/rootCA.pem (adds to the built-in list) |
Python requests |
REQUESTS_CA_BUNDLE pointing at a full bundle |
services:
some-go-app:
environment:
- SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt
volumes:
# system CA bundle with rootCA.pem appended: cat /etc/ssl/certs/ca-certificates.crt rootCA.pem > ca-bundle.crt
- ./ca-bundle.crt:/etc/ssl/certs/ca-bundle.crt:ro
some-debian-app:
volumes:
- ./certs/homelab-rootCA.crt:/usr/local/share/ca-certificates/homelab-rootCA.crt:ro
Write it in the compose file
I lost this once: the CA trust had been added by hand inside a container, and an image upgrade recreated the container without it. Webhooks then failed silently on every push. If a container needs your CA, put the mount and the variable in the compose file, with a comment saying why.
Only the public rootCA.pem goes into containers, never the key.
7. Renewal and expiry¶
- Monitor expiry. Uptime Kuma's HTTPS monitors can alert on certificate expiry; turn that on for at least one local name and one public name.
- Renew the leaf before it expires: run
mkcert "*.<YOUR_LOCAL_DOMAIN>"again on the workstation and replace the files in the existing NPM custom certificate, so every proxy host picks it up. - If you replace the CA or create a new cert entry, re-assign it on every proxy host. Hosts left on an old or
deleted certificate keep serving it (or silently fall back to plain HTTP). After any certificate change, run the
opensslcheck above against every local name. - When the root CA approaches its 10 years, plan a rollover: new CA on all clients first, then the new leaf.