If you run more than a couple of self-hosted admin tools on your LAN, you've hit the self-signed-certificate warning wall. Here's how to run your own internal certificate authority so every internal hostname gets a real, browser-trusted cert — no more click-through warnings.
Self-signed certs (or plain HTTP) for internal-only admin tools — your hypervisor, your NAS, your monitoring dashboard — are annoying in a way that compounds. Every new browser profile has to click through a warning once, bookmarks look untrustworthy, and browser automation tools often flatly refuse to attach to a page showing a certificate warning at all. A private internal CA fixes all of this at once: mint your own root certificate, trust it once per device, and every internal hostname just works.
step-ca (Smallstep) is a small, self-contained certificate authority that speaks ACME (the same protocol Let's Encrypt uses) — meaning tools that already know how to request a Let's Encrypt certificate can be pointed at your own CA instead with minimal changes. Run it in Docker with an env-driven first-boot bootstrap:
services:
step-ca:
image: smallstep/step-ca:latest
container_name: step-ca
restart: unless-stopped
ports:
- "9000:9000"
volumes:
- /opt/stepca/data:/home/step
environment:
DOCKER_STEPCA_INIT_NAME: "My Internal CA"
DOCKER_STEPCA_INIT_DNS_NAMES: "stepca.internal.example.com,<ca-host-ip>,localhost"
DOCKER_STEPCA_INIT_PROVISIONER_NAME: "acme"
DOCKER_STEPCA_INIT_ACME: "true"
DOCKER_STEPCA_INIT_PASSWORD_FILE: /home/step/secrets/password.txt
chown -R 1000:1000 before first boot, or the container can't write its own password/config files.
claims block to
both provisioners in ca.json, or every issued certificate expires in a day:
{
"type": "ACME",
"name": "acme-1",
"claims": {
"minTLSCertDuration": "5m",
"maxTLSCertDuration": "2160h",
"defaultTLSCertDuration": "720h"
}
}
Anything with a built-in ACME client (Proxmox, backup software, etc.) can just be pointed at your CA's ACME directory URL instead of Let's Encrypt:
pvenode acme account register default [email protected] \
--directory https://stepca.internal.example.com:9000/acme/acme-1/directory
pvenode config set --acme domains=pve.internal.example.com
pvenode acme cert order --force
Auto-renewal is handled by whatever timer/scheduler the product already runs for its own certs — nothing extra to set up.
If your reverse proxy (Caddy, in this case) already has a wildcard certificate loaded for your public domain
(e.g. a Cloudflare Origin certificate covering *.example.com), adding a per-site ACME directive
pointing at your internal CA will not trigger a new certificate request — even with correct
DNS and trust in place. The reverse proxy checks "is this domain already covered by any loaded certificate"
server-wide before deciding whether to run ACME automation for a given site, and a wildcard cert satisfies
that check regardless of which site block it was actually meant for.
The fix: mint certificates directly with the CA's own CLI (bypassing ACME's challenge-response flow entirely, which is fine since this is your own private CA), and load them as static files:
docker exec step-ca step ca certificate internal-app.example.com \
/home/step/certs/internal-app.crt /home/step/certs/internal-app.key \
--provisioner acme --password-file /home/step/secrets/password.txt -f
internal-app.example.com {
tls /config/internal/internal-app.crt /config/internal/internal-app.key
reverse_proxy <backend>
}
The tradeoff: static certs don't auto-renew like ACME-managed ones do. Fix it with a simple daily cron job that checks each cert's remaining validity and re-issues it once it's within ~10 days of expiry, copying the result to wherever your reverse proxy reads certs from.
Installing the root cert into your OS's system trust store is not enough on Linux — browsers keep their own separate certificate databases:
~/.pki/nssdb). Import
with certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "<name>" -i <root>.pem.certutil command, pointed at that profile's directory instead.Both browsers cache their trust store in memory for the life of the process — fully quit and relaunch (not just close the tab) after importing for the change to take effect.