Run more than a couple of self-hosted admin tools on your LAN and you have hit the self-signed-certificate warning wall. Here is 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 are annoying in a way that compounds. Your hypervisor, your NAS, your monitoring dashboard. Every new browser profile clicks through a warning once. Bookmarks look untrustworthy. Browser automation tools flatly refuse to attach to a page showing a certificate warning. A private internal CA fixes all of it at once. Mint your own root certificate, trust it once per device, and every internal hostname just works.
step-ca, from Smallstep, is a small self-contained certificate authority that speaks ACME. The same protocol Let's Encrypt uses. So any tool that already knows how to request a Let's Encrypt certificate points 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 cannot write its own password and 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 points at your CA's ACME directory URL instead of Let's Encrypt. Proxmox, backup software, plenty of others:
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
Whatever timer the product already runs for its own certs handles auto-renewal. Nothing extra to set up.
If your reverse proxy already has a wildcard certificate loaded for your public domain, Caddy in this case,
a per-site ACME directive pointing at your internal CA will not trigger a new certificate
request. Not even with correct DNS and trust in place. Say a Cloudflare Origin certificate covering
*.example.com. The reverse proxy checks "is this domain already covered by any loaded
certificate" server-wide, before it decides whether to run ACME automation for a site. A wildcard cert
satisfies that check whichever site block it was meant for.
Fix it by minting certificates directly with the CA's own CLI, then loading them as static files. That bypasses ACME's challenge-response flow entirely, which is fine on your own private CA:
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 is that static certs do not auto-renew the way ACME-managed ones do. Fix that with a daily cron job. Check each cert's remaining validity, re-issue once it is within about 10 days of expiry, and copy 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.Both browsers cache their trust store in memory for the life of the process. Fully quit and relaunch after importing, not just close the tab, or the change never takes effect.