Step-CA PKI Deployment — Internal Certificate Authority on fablehaven
Summary
TheBurrow required an internal Certificate Authority to issue TLS certificates for fleet services — Pi-hole admin UIs, Caddy reverse proxy, NetBox, and devpi — without relying on public ACME challenges or self-signed certs per service.
Step-CA was selected as the CA (see ADR #430) and deployed on
fablehaven as a systemd service via Ansible. The deployment covers
the CA itself (step_ca role), client certificate issuance and renewal
(step_ca_client role), and the integration patterns that connect them
to fleet services.
This lab note documents the architecture, the Ansible role design, the key operational decisions, and the notable failures encountered during deployment.
Architecture
CA topology
fablehaven (192.168.95.6)
└── step-ca (systemd service, port 9000)
├── Root CA certificate
├── Intermediate CA certificate
└── JWK provisioner (fablehaven-provisioner)
Fleet hosts (mistborn, hogwarts, fablehaven, locker9, conair, cosmere)
└── step_ca_client role
├── step CLI installed
├── Root CA cert trusted (system trust store)
├── Host certificate issued via JWK provisioner
└── Renewal timer (systemd, runs daily)
Certificate flow
step_carole deploys CA on fablehaven, generates root and intermediate certs, writesca.jsonwith JWK provisioner configstep_ca_clientrole runs on each fleet host:- Installs step CLI
- Fetches and trusts root CA cert
- Issues host cert via
step ca certificatewith JWK provisioner - Installs renewal wrapper script and systemd timer
- Caddy on fablehaven terminates TLS for Pi-hole admin UI using the host cert issued by the internal CA
- NetBox and devpi use certs issued by the same CA
Trust model
All fleet hosts trust the TheBurrow root CA via the system trust store
(/usr/local/share/ca-certificates/). Internal services use certs
signed by the intermediate CA. No public CA involvement.
Notes
Ansible role design
step_ca role (server)
Numbered task files following the repo convention:
roles/step_ca/tasks/
├── 00_guard.yml # scope guard
├── src/
│ ├── 05_preflight.yml # assert vault vars, OS check
│ ├── 10_install.yml # download step-ca binary, verify SHA256
│ ├── 20_user.yml # create step-ca system user
│ ├── 30_dirs.yml # create /etc/step-ca, /var/lib/step-ca
│ ├── 40_config.yml # initialize CA if not already done
│ ├── 50_service.yml # deploy systemd unit, enable, start
│ └── 55_service.yml # reload after config changes
Key design decisions:
- CA initialization is idempotent:
ConditionFileNotEmptyonca.jsonprevents re-init on subsequent runs - Binary installed to
/usr/local/bin/step-cawith SHA256 checksum verification against the fleet versions manifest - systemd service runs as
step-causer (not root) withProtectSystem=strictandPrivateTmp=true - JWK encryptedKey stored in vault as a valid JSON string (see case study: JWK encryptedKey corruption)
step_ca_client role (fleet)
roles/step_ca_client/tasks/
├── 00_guard.yml
├── src/
│ ├── 05_preflight.yml # assert fingerprint, CA URL, vault password
│ ├── 10_install.yml # download step CLI, verify SHA256
│ ├── 20_root_ca.yml # fetch root CA cert, add to trust store
│ ├── 30_cert.yml # issue host cert via JWK provisioner
│ └── 40_renewal.yml # install renewal wrapper + systemd timer
Key design decisions:
step ca certificateusesargvlist format (not shell string) to avoid quoting issues with SANs and paths- Provisioner password written to temp file, used for cert issuance,
deleted in
alwaysblock regardless of success or failure - Renewal wrapper script checks exit code: 0 = renewed, 1 = not yet due (normal), other = error
ansible_fqdnreturns short hostnames on fleet — useinventory_hostnamefor cert subject (e.g.,fablehaven.home.arpa)--fingerprintflag removed in step CLI v0.30.2 — use--rootwith the root CA cert path instead
Checksum verification pattern
All step CLI and step-ca binary downloads verified against SHA256
checksums stored in the fleet versions manifest
(group_vars/all/fleet_versions.yml):
fleet_versions:
step_ca:
version: "0.30.2"
arm64_sha256: "abc123..."
amd64_sha256: "def456..."
step_cli:
version: "0.30.2"
arm64_sha256: "..."
amd64_sha256: "..."
Architecture map in role vars handles arm64 (Pi fleet) vs amd64 (x86 hosts) automatically.
JWK provisioner bootstrap
The JWK provisioner private key cannot be generated by Ansible — it
requires an interactive step crypto jwk create command. The bootstrap
process:
- Generate JWK keypair on fablehaven manually
- Store
encryptedKey(JSON string) in vault viavault_edit.py - Store provisioner password in vault under
vault_secrets_step_ca.provisioner_password - Run
step_carole — ca.json is templated from vault values
See ADR #448 for the full decision rationale. See the JWK encryptedKey
corruption case study for what happens when vault_edit.py serializes
the value incorrectly.
Certificate subject and SANs
Fleet host certs use:
- Subject:
inventory_hostname(e.g.,fablehaven.home.arpa) - SANs:
inventory_hostname+ansible_host(IP address)
This ensures certs are valid for both DNS and IP-based access. Caddy uses the cert's SAN list when terminating TLS.
Renewal
Each fleet host runs a systemd timer daily that calls the renewal wrapper script:
#!/bin/bash
/usr/local/bin/step ca renew \
/etc/step/certs/host.crt \
/etc/step/certs/host.key \
--expires-in 720h \
--daemon
EXIT=$?
if [ $EXIT -eq 0 ]; then
systemctl reload caddy 2>/dev/null || true
fi
exit $EXIT
--expires-in 720h means renewal only occurs if the cert expires
within 30 days. Exit code 1 from step means "not yet due" — the
wrapper treats this as success.
Insights
The fragility of binary flag APIs
Step CLI v0.30.2 removed several flags that appeared in documentation
and were present in earlier versions: --fingerprint, --no-password,
--key-type, --curve. Each removal caused a deployment failure that
required a fix PR. Version-pinning the CLI and testing against the
pinned version before fleet rollout is essential.
argv format over shell strings for certificate commands
The step ca certificate command takes a subject, cert path, key path,
and multiple flags. Building this as a shell string creates quoting
problems with spaces in paths and SANs. Using Ansible's argv list
format eliminates the quoting surface entirely:
- name: Issue host certificate
ansible.builtin.command:
argv:
- /usr/local/bin/step
- ca
- certificate
- "{{ inventory_hostname }}"
- "{{ step_ca_client_cert_path }}"
- "{{ step_ca_client_key_path }}"
- "--provisioner"
- "{{ step_ca_client_provisioner_name }}"
- "--provisioner-password-file"
- "{{ step_ca_client_provisioner_password_file }}"
- "--root"
- "{{ step_ca_client_root_ca_path }}"
- "--san"
- "{{ ansible_host }}"
- "--not-after"
- "{{ step_ca_client_cert_lifetime }}"
- "--force"
Provisioner password cleanup is non-negotiable
The provisioner password grants the ability to issue certificates for
any hostname in the CA's namespace. It must be written to disk only for
the duration of cert issuance and deleted immediately after — success
or failure. Ansible block/always pattern:
- block:
- name: Write provisioner password
ansible.builtin.copy:
content: "{{ vault_step_ca_client_provisioner_password }}"
dest: "{{ step_ca_client_provisioner_password_file }}"
mode: "0600"
- name: Issue certificate
ansible.builtin.command:
argv: [...]
always:
- name: Remove provisioner password file
ansible.builtin.file:
path: "{{ step_ca_client_provisioner_password_file }}"
state: absent
Root CA path must use inventory_dir not playbook_dir
The root CA cert is stored relative to the inventory directory. Using
playbook_dir caused failures when playbooks were called from different
locations. inventory_dir is stable regardless of where the playbook
is invoked from.
Results
- Step-CA running on fablehaven, issuing certs for the fleet
- All Pi-hole nodes (mistborn, fablehaven, hogwarts) hold valid certs
- Caddy on fablehaven terminates TLS for Pi-hole admin UIs at
pihole-mistborn.home.arpa,pihole-fablehaven.home.arpa,pihole-hogwarts.home.arpa - NetBox accessible at
netbox.home.arpaover HTTPS - devpi accessible at
devpi.home.arpaover HTTPS - Daily renewal timers active on all cert-holding hosts
- Root CA trusted fleet-wide via system trust store
Related
- newcomb-labs/engineering-journal-kb #430 — ADR: internal CA tooling selection
- newcomb-labs/engineering-journal-kb #448 — ADR: JWK provisioner bootstrap strategy
- newcomb-labs/engineering-journal-kb #458 — lab note: Caddy reverse proxy
- newcomb-labs/engineering-journal-kb #467 — case study: JWK encryptedKey corruption
- mistborn-ops/ansible #287–#404 (step_ca, step_ca_client role PRs)
- newcomb-labs/engineering-journal-kb #457 (this issue)