vinnymarquez.dev/tutorials/internal https with step-ca
September 9, 20267 min readguide#homelab#security#pki#tls

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 ca root with no args errors with 'root certificate ... was not found' because it expects a bootstrapped ~/.step/config/defaults.json that a fresh server install doesn't create. don't chase it — read /etc/step-ca/certs/root_ca.crt directly.

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:

the @ in the provisioner name has to be URL-encodeddirectory URL 404s otherwise
the provisioner is named like acme@home.lan, and the @ in the directory URL must be written as %40 — https://<ip>/acme/acme%40home.lan/directory. with a literal @ the directory endpoint just 404s and the account registration fails with an unhelpful 'cannot init API' error.
importing the root cert into the wrong trust storeTLS verify failure (curl error 60) on account registration
on OPNsense specifically: System → Trust → Certificates is for end-entity certs the box presents, not CA anchors — pasting the root there does nothing, and registration fails with libcurl error 60. the system CA bundle that the ACME client's own curl validates against is fed by System → Trust → Authorities instead: Add → Import Existing Authority → paste the root PEM, no private key. same idea on any OS — the 'trusted CAs' store, not the 'my certificates' store.
the HTTP-01 challenge needs a real interface bound'unable to setup a port forward (empty ruleset)'
for an http-standalone / HTTP-01 challenge on OPNsense, the Interface field defaults to None and issuance fails with that error. step-ca and the router are both LAN-side — no WAN involved — the automation just needs an interface to bind its temporary port-80 listener to. set it to LAN. leave IP auto-discovery on auto (that field is the router's own address on the interface, not step-ca's).

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 GUI shows 'changes applied successfully' and the dropdown correctly shows the new cert, while openssl s_client -connect <ip>:443 still shows the old one — the process serving 443 (lighttpd on OPNsense) didn't reload it. force it from the console: configctl webgui restart. always confirm with a real TLS handshake, not just the browser.
the router blocks its own new hostname as a rebind attack'A potential DNS Rebind attack has been detected'
OPNsense (and other router OSes) treat any hostname resolving to an RFC1918 IP as untrusted unless allow-listed. add router.home.lan under System → Settings → Administration → Alternate Hostnames.

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
echo | openssl s_client -connect <ip>:<port> -servername app.home.lan 2>/dev/null | openssl x509 -noout -subject -dates — should show CN=app.home.lan and a fresh not-after date. a browser cache or a stale keychain entry throws ERR_CERT_AUTHORITY_INVALID, which is a different symptom than an old-but-trusted cert still being served.

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
Restart=on-failure + RestartSec=30 looks resilient, but systemd's default restart-burst limit still applies without a StartLimitIntervalSec override. an outage lasting a few hours (step-ca unreachable, the app's host disk full, whatever) blows through the burst limit and parks the unit in 'failed (Result: start-limit-hit)' — permanently, no more retries, no alert. the 7-day cert then just expires. mine did exactly this: a disk-full on an unrelated container took step-ca's neighbour down, the renew daemons hit the burst ceiling, and nobody noticed until a browser padlock went red ~2 weeks later. StartLimitIntervalSec=0 removes the ceiling entirely — the daemon retries every 30s forever and self-heals the instant step-ca is back. it costs nothing. put it on every renewal unit, and re-run daemon-reload on any that are already deployed — editing the template doesn't touch running units.
diagnosing a host whose disk is 100% fullthe container console won't even open
a full root disk leaves no room to fork a shell, so the container's own console is dead. from the hypervisor host, pct exec <id> -- df -h / and pct exec <id> -- du -shx /root/* still work — they don't need the container to have free space. a full pct reboot <id> restores console access and restarts every service fresh once you've cleared space.

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.

▌ comments