Internal CA (step-ca)#

FalseBlue runs a private step-ca certificate authority on ereshkigal (modules/nixos/pki/step-ca.nix). Every NixOS/darwin host in the flake gets and auto-renews its cert over ACME with no manual steps (tsunaminoai.pki.acme → security.acme pointed at https://ca.falseblue.com/acme/acme/directory). You only need the commands below for devices that aren’t in the flake — the biggest one being the Home Assistant appliance (HAOS), which serves its own cert on :8123.

Fact Value
CA URL https://ca.falseblue.com
Root fingerprint 66b8732f7a4630403455da2ef05fef269ae63e390c6b5f50f82ecdf872585fe6
Root cert (in repo) pki/authorities/falseblue.crt (CN=falseblue.com, valid to 2032)
Interactive provisioner tsunami@falseblue.com (JWK, password-protected)
ACME provisioner acme (what the flake hosts use)
Cert lifetimes default 32 days, max 90 days (--not-after up to 2160h)

step-ca listens on :9443 and is fronted at https://ca.falseblue.com (:443). Certificates for *.falseblue.com, *.<tailnet>, homeassistant, and any 192.168.0.0/24 / 100.0.0.0/8 IP are permitted by policy.

Run the step client on a flake host

ereshkigal (and any NixOS host importing the PKI module) already has step-cli on PATH and trusts the root system-wide, so it’s the easiest place to run these commands. HAOS can’t easily run step itself — issue the cert on ereshkigal, then copy the files onto the appliance.

One-time: bootstrap trust to the CA#

On a machine that doesn’t already trust the CA (skip on flake hosts):

step ca bootstrap \
  --ca-url https://ca.falseblue.com \
  --fingerprint 66b8732f7a4630403455da2ef05fef269ae63e390c6b5f50f82ecdf872585fe6

This writes ~/.step/ with the root and CA config so later step ca … commands need no --ca-url/--root flags.

Renew a cert that is still valid#

If the certificate hasn’t expired yet, renewal is a single command — it re-authenticates with the existing cert+key (mutual TLS), no provisioner password needed:

step ca renew host.crt host.key --force

Expired certs cannot be renewed

The CA sets allowRenewalAfterExpiry = false. Once a cert is past notAfter, step ca renew fails — you must re-issue it (next section). This is the case for the Home Assistant cert today (it expired before anyone noticed — the exact gap the status page now closes).

Issue (or re-issue) a certificate#

Use the interactive JWK provisioner. You’ll be prompted for the provisioner password (set at step ca init; kept in sops under step-ca/password-file).

Example for Home Assistant (both the homeassistant DNS name and its LAN IP as SANs, 90-day lifetime):

step ca certificate "homeassistant" ha.crt ha.key \
  --provisioner "tsunami@falseblue.com" \
  --san homeassistant \
  --san 192.168.0.55 \
  --not-after 2160h

For a scripted / non-interactive flow, split it into a token + issuance (the token carries the authorization; hand it to whoever generates the CSR):

TOKEN=$(step ca token homeassistant \
  --provisioner "tsunami@falseblue.com" \
  --san homeassistant --san 192.168.0.55 \
  --not-after 2160h \
  --password-file /run/secrets/step-ca/password-file)   # on ereshkigal

step ca certificate homeassistant ha.crt ha.key --token "$TOKEN"
or
step ca token homeassistant.armadillo-banfish.ts.net \
                     --provisioner "tsunami@falseblue.com" \
                     --ca-url https://ca.falseblue.com:9443 \
                     --root /home/tsunami/code/nix/nix-flake-final/pki/authorities/falseblue.crt \
                     --san 192.168.0.55 \
                     --san homeassistant.gensokyo \
                     --san homeassistant \
                     --san homeassistant.armadillo-banfish.ts.net

Install the cert on Home Assistant (HAOS)#

HAOS is not managed by this flake, so this part is manual:

  1. Copy ha.crt and ha.key into the appliance’s /ssl/ directory (via the Samba/File-Editor add-on, or scp if SSH is enabled).
  2. In configuration.yaml:
    http:
      ssl_certificate: /ssl/ha.crt
      ssl_key: /ssl/ha.key
    
  3. Restart Home Assistant (Developer Tools → YAML → Restart, or reboot).
  4. Verify: openssl s_client -connect 192.168.0.55:8123 -servername homeassistant </dev/null 2>/dev/null | openssl x509 -noout -issuer -dates should now show issuer=CN=falseblue.com and a fresh notAfter.

Automating renewal#

For a Linux host you control, run step ca renew on a timer well before expiry (renew at ~2/3 of lifetime):

step ca renew --daemon --expires-in 20d host.crt host.key \
  --exec "systemctl reload nginx"

HAOS makes long-running daemons awkward; the pragmatic path for the appliance is a calendar reminder to re-issue every ~2 months, or move HA behind the step-ca nginx edge like the other services so it never needs a device-local cert at all.

Known issue: ca.falseblue.com DNS flap#

Status: mitigated, not fixed (found 2026-08-12).

ca.falseblue.com resolves non-deterministically from LAN hosts. MagicDNS / AdGuard sometimes answer with the public Cloudflare anycast IPs (→ CF tunnel → voile’s cloudflared → step-ca; the working path) and sometimes with the LAN rewrite 192.168.0.20 — ereshkigal itself. ereshkigal’s nginx has no ca.falseblue.com server block (its only :443 vhost is status.falseblue.com), so on the LAN answer lego receives the wrong cert and dies with x509: certificate is valid for status.falseblue.com, not ca.falseblue.com. Internal ACME issuance is therefore a coin flip per attempt, and the failure looks nothing like DNS. The name is not pinned in networking.hosts (only status.falseblue.com is).

  • Mitigation in place: modules/nixos/acme/default.nix gives every acme-order-renew-* unit Restart=on-failure bounded to 3 tries per hour, so a lost coin flip is retried instead of sitting dead until the next timer tick. nginx also now waits for tailscale-online.service (modules/nixos/ollama-proxy/default.nix) so it no longer hard-fails at boot on an unresolvable tailnet upstream and races the HTTP-01 challenge.
  • Candidate fixes (pick one): pin ca.falseblue.com → 192.168.0.20 in networking.hosts next to the existing status.falseblue.com entry and set the ACME server to https://ca.falseblue.com:9443/acme/acme/directory (step-ca’s leaf already covers DNS:ca.falseblue.com and IP:192.168.0.20, so no reissue is needed); or add the missing ca vhost proxying to :9443; or drop the LAN/split-DNS A record. Tracked in In-flight work.
  • Debug notes: smallstep reports any HTTP ≥ 400 from the challenge URL as urn:ietf:params:acme:error:connection, so a 502 is indistinguishable from a refused connection — nginx’s access log is the arbiter of whether the CA’s GET arrived. The NixOS ACME script exits 11 when a renewal failed and the cert is inside lego’s --dynamic window, which is why a miss stays silent for weeks. Related: the status page cites the ca.falseblue.com LAN rewrite as precedent — it is precisely the broken path.

How this ties into monitoring#

  • The status page probes Home Assistant with client.insecure = true (we don’t trust its self-signed cert), and that endpoint is labelled extra-labels.cert = "external" so the ProbedCertificateExpiringSoon alert skips it — an unmanaged appliance cert isn’t our renewal to watch.
  • Every endpoint whose cert is ours (the *.falseblue.com / *.<tailnet> vhosts) is watched: that alert fires at <14 days remaining, catching a silently-stuck ACME renewal before browsers start erroring.
  • If you issue a proper FalseBlue cert for HA (above) and want it monitored, drop client.insecure and the cert = "external" label from its endpoint in the ereshkigal host config.
  • status-page.md — the synthetic checks + cert-expiry alert.
  • backups.md — the step-ca BadgerDB is backed up to voile/borg daily.