Trust No One: Building an Internal PKI, Identity Platform, and Secrets Vault
At some point in every home lab’s evolution, you hit the wall. Not a hardware wall — you can always buy more RAM. The wall is the moment you realise your entire security posture consists of self-signed certificates that every browser complains about, passwords stored in a spreadsheet, and “authentication” that amounts to hoping nobody else is on your network.
The answer to this wall is a dedicated identity VM. We called it DebIdentity, and it runs three services that, once you have them, make you wonder how you tolerated the alternative.
The Three Pillars
Step-CA — Internal Certificate Authority. Issues TLS certificates for every service in sdx.local that are trusted by every device in the lab, eliminating browser warnings and enabling real mutual TLS.
Authentik — Identity Provider. Central authentication for all VMs and services via LDAP, OIDC, and SAML. One login, everywhere.
VaultWarden — Password vault. Compatible with the Bitwarden client ecosystem. Stores every credential, API key, and secret in the lab, backed by SSO via Authentik.
All three run in Docker on DebIdentity, a Debian 12 VM at 172.16.50.3. Traefik handles TLS termination using Step-CA certificates, which means every service behind it gets a trusted HTTPS endpoint automatically.
Step-CA: Making Browsers Stop Complaining
The smallstep Step-CA is a production-grade internal Certificate Authority that runs as a Docker container and issues certificates on demand. The setup involved:
- Generating a root CA keypair (“SDX Lab CA Root CA”)
- Distributing the root CA certificate to the Windows trust store on REX, the macOS trust store on other machines, and every VM’s system trust store
- Configuring Step-CA to issue certificates with a 3-year validity period (26280 hours)
That last point deserves explanation. Internal CAs running in homelabs have a rotation problem: if you issue 90-day certificates like Let’s Encrypt does, you need automation to renew them, and automation that fails at 3am means your entire lab is down when you wake up. Three-year validity is a deliberate tradeoff — slightly less “correct” from a security textbook perspective, and considerably less likely to ruin a Sunday morning.
The certificates issued cover: litellm.sdx.local, auth.sdx.local, vault.sdx.local, pfSense, Aruba, and the Synology NAS. Every service in the lab now has TLS that browsers, CLI tools, and Python’s requests library all trust without additional flags.
Authentik: Centralised Identity
Authentik is an open-source identity provider that supports LDAP, OIDC, SAML, and RADIUS. For a home lab, this is significant overkill. This is the correct amount of overkill.
The deployment runs Authentik server + worker containers, backed by PostgreSQL and Redis. The integration surface covers:
LDAP outpost (port 389/636): Every Linux VM runs SSSD configured to authenticate users against Authentik’s LDAP outpost. This means the lab owner’s account is a real user on every VM without local accounts being duplicated manually.
RADIUS outpost (ports 1812-1813/UDP): Juniper switches, pfSense, and the Aruba wireless controller all authenticate network device logins against Authentik via RADIUS. SSH into the Juniper core switch uses the same credentials as logging into a VM.
OIDC for VaultWarden: VaultWarden’s login page redirects through Authentik for SSO. You authenticate once to access both the identity layer and the secrets layer.
Service accounts in Authentik follow a clear naming convention — short, descriptive, scoped to function:
- An AI infrastructure account for SSH operations against network devices (read-only JunOS access by default)
- A local service account for the AI assistant, deliberately not Authentik-managed to avoid circular dependencies at boot
- A dedicated VaultWarden CLI account for API key access from WSL2 scripts
The claude and ai-agent accounts that existed in early lab iterations were deprecated once it became clear that generic AI agent accounts with undefined scope are a security smell. Named, scoped accounts only.
VaultWarden: Secrets Done Right
VaultWarden is a Bitwarden-compatible server that runs in Docker and provides a full password management solution with browser extension support, TOTP, and organisations for sharing credentials.
Every credential in the lab lives here:
- Network device passwords (Juniper, pfSense, Aruba, Synology)
- API keys (LiteLLM master key, GCP service account keys, Gemini API key)
- SSH private keys for service accounts
- OIDC client secrets for Authentik integrations
- The WiFi password (yes, even that)
Automated secret retrieval works via the Bitwarden CLI running as vault-cli@sdx.local in WSL2. MCP server start scripts call vault-get-secret.sh before starting, pulling credentials at runtime rather than storing them in environment files. The env file fallback (~/.pfsense-env) exists for disaster recovery when VaultWarden is unavailable — but VaultWarden being unavailable is a DebIdentity problem, and DebIdentity has a UPS.
The Use Case: Eliminating Credential Sprawl
Before DebIdentity, the lab’s secret management strategy was a collection of environment files, a shared password in a notes app, and at least one hardcoded API key in a config file that shall not be named.
After DebIdentity, every service authenticates via a named account, every API key lives in VaultWarden behind MFA, and rotating a credential means updating one record rather than hunting through a dozen config files. The MCP server for pfSense pulls its API key from VaultWarden at startup. The LiteLLM proxy config references the master key via environment injection. The Claude Code configuration reads the LiteLLM key from the same VaultWarden record.
The cognitive overhead of managing secrets dropped significantly. The security posture improved in proportion.
The Uncomfortable Truth About Internal PKI
The part nobody tells you about running your own CA: the first thing you have to do is distribute the root certificate to every device that needs to trust it. On Windows, this is a Group Policy or a manual import into the certificate store. On macOS, it’s a similar process via Keychain. On every Linux VM, you add it to /usr/local/share/ca-certificates/ and run update-ca-certificates.
This takes about an afternoon. It involves a USB drive and more scp commands than you’d like. The result — every browser, every curl, every Python script trusting your internal certificates without complaint — is worth every minute of it.
The alternative is living with curl --insecure flags in your automation scripts, which is the infrastructure equivalent of a smoke detector running on an AA battery you’ve been meaning to replace.