Internal HTTPS With step-ca
a private certificate authority so your LAN-only services get real, trusted HTTPS instead of a browser 'Not Secure' — ACME for the ones that support it, a renewal daemon for the ones that don't, and the silent failure mode that broke mine for two weeks.
everything LAN-only in my setup — the router GUI, a password manager, a dashboard — was either plain HTTP or serving a self-signed cert that every browser slaps a “Not Secure” on. step-ca fixes that: it’s a self-hosted private CA that issues real certs for your internal hostnames. install its root once per device and those warnings go away, for good, without any of it touching the public internet.
public-facing services still need a public CA (Let’s Encrypt) — a random visitor’s phone won’t trust your private root. this is specifically for the stuff only you and your devices ever reach.
the moving parts
| piece | what it is |
|---|---|
| step-ca | the CA daemon — holds the root + intermediate keys, answers issuance requests |
| a JWK provisioner | for manual step CLI cert requests — you type a password, you get a cert |
| an ACME provisioner | for automated issuance — anything that speaks ACME (like a router’s ACME client) points at this |
| internal DNS zone | your hostnames (thing.home.lan) need A records pointing at the LAN IPs |
| the root cert | imported into every client’s trust store — this is what makes the whole thing “trusted” |
step 1 — deploy step-ca
a small dedicated container is the right home for it. the Proxmox community-scripts collection has a one-liner that installs the native step-ca + step-cli packages as a systemd service (not Docker):
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/step-ca.sh)"
it prompts for the PKI identity. these bake into the root cert’s subject at install time — you can’t edit them later without a full reinit, so get them right:
| prompt | value |
|---|---|
| PKI name | something identifiable — HomePKI |
| country / org unit | your call |
| JWK provisioner name | default is fine — pki@home.lan |
| ACME provisioner name | default is fine — acme@home.lan |
| cert lifetime (min / max / default) | defaults are fine — commonly 48h / 10y / 7 days |
note that 7-day default lifetime — it’s short on purpose (a leaked cert self-expires fast), which is exactly why renewal automation below isn’t optional.
grab the root cert now, you’ll need it repeatedly:
cat /etc/step-ca/certs/root_ca.crt
`step ca root` fails on a fresh installnot broken — just not bootstrapped
step 2 — let the ACME provisioner actually issue anything
with no explicit policy, step-ca’s ACME provisioner denies every identifier — not “allows everything except IPs”, which is the wrong assumption you’ll make when your first issuance fails with The server will not issue certificates for the identifier.
systemctl stop step-ca
nano /etc/step-ca/config/ca.json
add a "policy" key as a sibling of the ACME provisioner’s "claims":
{
"type": "ACME",
"name": "acme@home.lan",
"claims": { },
"policy": {
"x509": {
"allow": {
"dns": ["*.home.lan"]
}
}
}
}
systemctl start step-ca
this also means every cert must have a DNS common name, not a raw IP — router.home.lan, not 192.168.x.x.
step 3, path A — services that speak ACME
your router/firewall OS very likely has an ACME client (OPNsense ships os-acme-client). point it at step-ca instead of Let’s Encrypt:
- CA / account: set the CA type to Custom. email can be anything — step-ca doesn’t validate it.
- ACME directory URL:
https://<step-ca-ip>/acme/<provisioner-name>/directory
the @ in the provisioner name has to be URL-encodeddirectory URL 404s otherwise
importing the root cert into the wrong trust storeTLS verify failure (curl error 60) on account registration
the HTTP-01 challenge needs a real interface bound'unable to setup a port forward (empty ruleset)'
then create the cert: common name router.home.lan (a DNS name), your custom account, the challenge type you set up. issue it, apply it to the GUI’s SSL settings.
the web server keeps serving the old cert after you 'apply'config saved ≠ process reloaded
the router blocks its own new hostname as a rebind attack'A potential DNS Rebind attack has been detected'
don’t forget the A record — add router → the LAN IP in your internal DNS zone, or the nice hostname resolves nowhere.
step 3, path B — apps with no ACME client
most self-hosted apps don’t speak ACME. two sub-cases:
B1 — the app has a native TLS cert/key setting (e.g. Vaultwarden’s ROCKET_TLS). issue a cert once with the JWK provisioner and point the app at it.
B2 — the app has no TLS support at all (anything on a bare next start, plenty of others). put nginx in front — it terminates TLS and forwards plain HTTP to localhost:<port>, and the app’s own install stays untouched. this is also the update-proof choice: no patching the app’s launch script after every upgrade.
issue the cert (run on a box that’s bootstrapped to the CA — step-ca’s own container already is):
# one-time, from any other box:
step ca bootstrap --ca-url https://<step-ca-ip> --fingerprint <root-fingerprint>
# ^ get the fingerprint with: step certificate fingerprint /etc/step-ca/certs/root_ca.crt
step ca certificate app.home.lan app.crt app.key --provisioner pki@home.lan
drop app.crt / app.key where the app (or nginx) reads them, fix ownership to whatever user the service runs as, 640/600 on the key, restart.
confirm the served cert, don't trust 'restarted'same reload trap as the router
step 4 — automate renewal for the path-B certs (this is the important bit)
ACME clients renew themselves. path-B certs don’t — and with a 7-day lifetime, a cert you issued by hand is dead by next weekend. run step ca renew --daemon as its own systemd service on each path-B host.
# install step-cli on the app's host (separate box from step-ca)
wget -qO- https://packages.smallstep.com/keys/apt/repo-signing-key.gpg | gpg --dearmor -o /etc/apt/trusted.gpg.d/smallstep.gpg
echo 'deb [signed-by=/etc/apt/trusted.gpg.d/smallstep.gpg] https://packages.smallstep.com/stable/debian debs main' | tee /etc/apt/sources.list.d/smallstep.list
apt-get update && apt-get install -y step-cli
step ca bootstrap --ca-url https://<step-ca-ip> --fingerprint <root-fingerprint>
a renewal hook (/opt/app/renew-hook.sh, chmod +x) — fixes ownership after root writes the new cert, then restarts the app:
#!/bin/bash
chown app:app /opt/app/app.crt /opt/app/app.key
chmod 640 /opt/app/app.crt
chmod 600 /opt/app/app.key
systemctl restart app.service
the unit (/etc/systemd/system/step-ca-renew-app.service):
[Unit]
Description=step-ca cert renewal for app
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
ExecStart=/usr/bin/step ca renew --daemon --exec /opt/app/renew-hook.sh /opt/app/app.crt /opt/app/app.key
Restart=on-failure
RestartSec=30
[Install]
WantedBy=multi-user.target
systemctl daemon-reload && systemctl enable --now step-ca-renew-app.service
journalctl -u step-ca-renew-app -n 20 # want a "first renewal in <N>h" line, well under 7 days
StartLimitIntervalSec=0 is not optional — leaving it out broke mine for two weekssilent: the daemon parks dead and nothing tells you
diagnosing a host whose disk is 100% fullthe container console won't even open
step 5 — install the root cert on every client
a real cert from step-ca still isn’t trusted until each device trusts the root. symptom without it: NET::ERR_CERT_AUTHORITY_INVALID on every internal hostname.
macOS — the Keychain Access GUI import fails on recent macOS with Error: -25294 even on a clean PEM. use the CLI, which imports + always-trusts in one step:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/Downloads/home-root.crt
iOS — AirDrop or email the .crt → install the profile in Settings → General → VPN & Device Management → then Settings → General → About → Certificate Trust Settings → toggle full trust on. the second step is separate and easy to miss.
Linux / Windows / Firefox each have their own trust store — Firefox notably ignores the OS store by default. do this once per device that will ever open an internal hostname.
if you segment your network later
a few things assume a flat LAN and need revisiting at a VLAN cutover: which interface the ACME challenge binds to (a subdivided LAN stops meaning anything), an inter-VLAN rule permitting every host that renews → step-ca on 80/443, and the cert common names if the router’s per-interface IPs change. worth noting in the build now so it’s not a surprise later.