← All Posts POST #03 · · 8 min read

Trust No One: Building an Internal PKI, Identity Platform, and Secrets Vault

Self-signed certificates are the 'password123' of TLS. Here's how DebIdentity got its own Certificate Authority, an enterprise identity provider, and a password vault - all in Docker.

#authentik#vaultwarden#step-ca#pki#identity#security

At some point in every home lab’s evolution, you hit a wall. Not a hardware wall - you can always buy more RAM. The wall is the moment you realise your entire security posture is a pile of self-signed certificates every browser complains about, passwords in a spreadsheet, and “authentication” that amounts to hoping nobody else is on your WiFi.

My answer was a dedicated identity VM. I called it DebIdentity, and it runs three services that, once you have them, make you wonder how you put up with the old way.

The Three Pillars

  • Step-CA - Internal Certificate Authority. Issues TLS certificates for every service in sdx.local that are trusted across the lab, killing off browser warnings and enabling mutual TLS.
  • Authentik - Identity Provider. Central login for VMs and services over LDAP, OIDC, SAML, and RADIUS.
  • VaultWarden - Password vault compatible with the Bitwarden client ecosystem. Stores lab credentials, API keys, and secrets behind Authentik SSO.

All three run in Docker on DebIdentity, a Debian 12 VM at 172.16.50.3. Traefik handles TLS termination using Step-CA certificates, so every service behind it gets a trusted HTTPS endpoint out of the box.

Step-CA: Making Browsers Stop Complaining

The smallstep Step-CA container runs an internal Certificate Authority and issues certs on demand. Setting it up came down to three steps:

  1. Generating a root CA keypair (“SDX Lab CA Root CA”)
  2. Installing the root CA certificate into the Windows trust store on REX, the macOS Keychain on other machines, and every VM’s system trust store
  3. Configuring Step-CA to issue certificates with a 3-year validity period (26,280 hours)

That three-year validity is deliberate. Internal CAs in homelabs have a renewal problem: if you issue 90-day certs like Let’s Encrypt, you need renewal automation on every appliance, and automation that breaks at 3am means your lab is down when you wake up. Three-year validity is slightly less textbook-pure, and much less likely to ruin a Sunday morning.

The issued certs cover litellm.sdx.local, auth.sdx.local, vault.sdx.local, OPNsense, Aruba, and the Synology NAS. Every service in the lab now serves TLS that browsers, CLI tools, and Python’s requests library trust without extra flags.

Authentik: Centralised Identity

Authentik is an open-source identity provider supporting LDAP, OIDC, SAML, and RADIUS. For a home lab, this is serious overkill - which is the right amount of overkill.

The stack runs Authentik server and worker containers backed by PostgreSQL and Redis:

  • LDAP outpost (port 389/636) - Every Linux VM runs SSSD against Authentik’s LDAP outpost. This means my account is a real user on every VM without duplicating local accounts by hand.
  • RADIUS outpost (ports 1812-1813/UDP) - Juniper switches, OPNsense, and the Aruba wireless controller authenticate management logins against Authentik via RADIUS. SSH into the core switch uses the same credentials as logging into a VM.
  • OIDC for VaultWarden - VaultWarden redirects through Authentik for SSO, so one login covers both identity and secrets.

Service accounts in Authentik follow a simple rule - short, descriptive, and scoped to one job:

  • An AI infrastructure account for SSH access to network gear (read-only JunOS access by default)
  • A local service account for the AI assistant, kept outside Authentik on purpose to avoid circular boot dependencies
  • A dedicated VaultWarden CLI account for API key access from WSL2 scripts

I retired the generic claude and ai-agent accounts from early lab iterations once it became obvious that shared agent accounts with fuzzy scope are a bad habit. Named, scoped accounts only.

VaultWarden: Secrets Done Right

VaultWarden is a lightweight Bitwarden-compatible server running in Docker with browser extension support, TOTP, and shared collections.

Every credential in the lab lived here initially:

  • Network device passwords (Juniper, OPNsense, 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 ran through the Bitwarden CLI in WSL2. MCP server start scripts called vault-get-secret.sh at launch, pulling credentials at runtime instead of keeping them in plaintext files. An env file fallback (~/.opnsense-env) sat on disk for disaster recovery if DebIdentity was ever unreachable - though DebIdentity is on a UPS.

The Use Case: Eliminating Credential Sprawl

Before DebIdentity, my secret management strategy was a scatter of .env files, a password in a notes app, and at least one hardcoded API key in a config file that shall not be named.

With DebIdentity in place, every service authenticated with a named account, every API key sat behind MFA, and rotating a secret meant updating one record instead of grepping through a dozen scripts.

The Uncomfortable Truth About Internal PKI

The part nobody warns you about when you stand up your own CA is day one: you have to distribute the root certificate to every device that needs to trust it. On Windows, that’s a cert store import. On macOS, it’s Keychain Access. On every Linux VM, you drop it into /usr/local/share/ca-certificates/ and run update-ca-certificates.

It takes an afternoon, a USB stick, and more scp commands than you’d like. The payoff - every browser, every curl, and every Python script trusting your internal endpoints without complaint - is worth every minute.

The alternative is living with curl --insecure scattered across your scripts, which is the infrastructure equivalent of taking the battery out of a beeping smoke detector.


Update - August 2026

Of the three pillars above, one is gone, one is untouched, and one is doing less than this post claimed.

I decommissioned VaultWarden on 11 June 2026. I removed the container, its data, the Authentik OIDC app, the CLI service account, and the Bitwarden CLI. Lab credentials now live in a single age-encrypted file on the host, unlocked with a passphrase when needed, with a small helper script for adds and updates so secrets never land in shell history.

That sounds like a step backwards, and mostly isn’t. The MCP start-up scripts above - vault-get-secret.sh piping a secret into an env file at launch - broke whenever the vault container had a bad day. They were built for a multi-user threat model (browser extensions, TOTP, shared orgs) that a one-person lab doesn’t have. One encrypted file is one less service to keep alive. The trade-offs are real though - there’s no read audit trail, and unattended jobs can’t use it directly because a scheduled task can’t type a passphrase at 7.55am. For those jobs, the Telegram bot token and a copy of the read-only switch key sit outside the vault as ACL-locked files.

Step-CA is untouched and still the best thing on this VM. It runs on port 9443 on DebIdentity with a JWK provisioner, and every management endpoint in the lab - firewall, NAS, wireless controller - serves a cert it issued. A cert audit in June caught the wireless controller quietly serving its factory self-signed cert because its web-server profile pointed at a cert name that didn’t exist. It had been doing that for months without me noticing, which is what “just click through the browser warning” costs you. The new core switch dodges the problem completely: its web UI is disabled, so it’s SSH and NETCONF only.

Authentik is doing less than advertised. Two of the three integrations in this post had quietly stopped pulling their weight by August:

  • RADIUS was broken on Aruba - The Aruba controller still pointed at the Authentik RADIUS outpost, and that server group never accepted - its counters showed zero accepts against a hundred rejects and 152 timeouts. With a five-second timeout and three retries, every login to the wireless controller (including the MCP server’s) burned ~20 seconds before falling back to the local account. That delay broke the weekly config backup in July until I bumped the script’s timeout.
  • The switch wasn’t using RADIUS at all - I dropped RADIUS from the core switch during the emergency rebuild in June and hadn’t re-added it. Switch access used a read-only local account for the MCP server and a break-glass super-user account for changes.
  • LDAP stayed alive and got hardened - Every Linux VM still authenticates via SSSD against the LDAP outpost, and since 7 July 2026 it runs over LDAPS on port 636 end to end with cleartext 389 closed.

The service-account model held up well. The AI automation account is an Authentik LDAP identity, paired with a local break-glass account on each host so an identity-provider outage never locks me out of the lab.


Update - October 2026

I fixed both broken RADIUS integrations in September, and the single age file grew into a proper secrets CLI.

  • RADIUS works across all three network appliances again - with zero login delay - The Aruba 20-second login penalty came down to missing vendor-specific attributes (VSAs) and mismatched RADIUS bindings in Authentik. Adding custom RADIUS property mappings that return Aruba-Admin-Role for the controller and Juniper-Local-User-Name for the core switch brought all three devices (Juniper core, OPNsense, and Aruba controller) onto central RADIUS auth. On the Juniper core, RADIUS logins now map cleanly into two separate local templates: a read-only template for default MCP queries and a super-user template for admin accounts.
  • The age file became a session-cached vault CLI - After catching a downstream cloud backup job in September mirroring plaintext .env files and unencrypted appliance config dumps off the NAS, I eliminated plaintext credential files across the workspace. A small Python wrapper (vault) now decrypts the age store once per login session, caches the derived key in the OS credential manager, and injects secrets straight into child process environments or ephemeral SSH key pipes. Credentials never sit in .env files on disk and never show up in AI session transcripts. Appliance config backups are now age-encrypted at capture time before they touch the NAS.