The Journey, Not the Urgency#
If you’ve been following TazLab’s story for a few months, you’ve noticed a common thread: the migration toward dynamic secrets. Not replacing one secret provider with another, but changing paradigms — from static credentials, written in YAML manifests and rotated manually, to credentials generated on the fly, with leases, automatic rotation, and zero human contact.
In the previous post I described the first steps: JWT auth, Vault Agent Injector, the Talos PKI disaster. Then the discovery of VSO and the decision to abandon the injector. Then the formalization of the CRISP method: research before writing code, verification markers in plans, mandatory reviews.
Every step paved the way for this project: the PKI.
The end goal? Being able to replicate the PostgreSQL database across two clusters (Proxmox + Hetzner) with automatic failover. For that, you need mTLS — client certificates, not passwords — because static passwords don’t survive a failover, and dynamic ones generate leases that expire at the wrong restart. The PKI was already in the long-term plan to raise the lab’s security level. It became an operational prerequisite for the next step.
This article tells that story: a month of planning, sixteen researches, fifteen reviews, and one day of implementation.
The Architecture: A Three-Tier PKI for a Two-Node Cluster#
Before writing a single line of code, I spent weeks in the design phase. The CRISP method I implemented required answering a fundamental question: what is the right architecture for a PKI that must be secure yet manageable on a Talos cluster with one control plane and one worker?
The answer was a three-tier PKI — overkill for a homelab, but that’s exactly what I wanted to learn:
- Tier 0: Offline Root CA (ECDSA P-384, 20-year validity) — generated on an air-gapped machine, key encrypted with GPG AES-256, never exposed online. It’s the anchor of trust. If Vault is compromised, the Root CA stays safe.
- Tier 1: First Intermediate CA (10 years) — imported into Vault as
pki_root. - Tier 2: Second Intermediate CA (5 years) — generated and managed entirely by Vault as
pki_int, with specific roles for each certificate type.
The domain split:
- Public (browser trust): blog.tazlab.net, wiki.tazlab.net, tazlab.net → Let’s Encrypt via cert-manager
- Internal (tailnet): auth.tazlab.net, dex.tazlab.net,
*.tazlab.net→ Vault PKI - Database: PostgreSQL server TLS + client certificates for each user → Vault PKI
The Research: The Real Work Was Understanding#
The CRISP method I implemented requires every design decision to be confirmed by external sources — no reliance on language model memory. This led to a structured research process that produced sixteen research documents, ten in the design phase and six during implementation.
The Design Phase: R26—R35#
- VaultPKISecret with JWT+JWKS: how VSO integrates with the PKI engine
- PKI Role Configuration for Kubernetes: critical parameters like
no_store,allowed_domains,allow_wildcard_certificates - Talos PKI Trust Bundle: TrustedRootsConfig and the differences between
machine.certSANsandcluster.apiServer.certSANs - TLS Certificate Migration: Static to VaultPKI: why gradual cutover beats big-bang
- Multi-Namespace TLS Secret Distribution: reflector vs manual copy
- Vault PKI PostgreSQL Security: how to set up mTLS for the database
- Crunchy PGO v5 mTLS Vault: how PGO handles certificates
- Vault PKI Chain Resolution: why the certificate chain was incomplete
- VSO Secret Transformation Configuration: how to dynamically generate the
ca.crtfile - Architectural Verification: validating the overall approach
Each research produced a Markdown document archived in the CRISP project. Many revealed complexities that a superficial analysis would have missed.
The Implementation Phase: Six Field Researches#
- Enabling TrustedRootsConfig on Talos: why
kubectl applyfailed andtalosctl patch mcwas needed - PostgreSQL mTLS Configuration for Applications: each client (psql, Grafana, pgAdmin, Go) has different requirements
- Resolving Vault PostgreSQL TLS Error: why
ca_chainwas empty despiteset-signed - Crunchy PGO Custom CA Configuration: how PGO makes
ssl_ca_fileimmutable - Grafana Kube-Prometheus-Stack Configuration: why
grafana.commandis ignored - Grafana PostgreSQL mTLS Helm Setup: the final configuration that worked
Without these researches, I’d still be trial-and-erroring on problems that — once understood — were solved in minutes.
The Reviews: Fifteen Cycles to Refine the Design#
The CRISP method involves structured review cycles. For this project I did three major ones, each identifying between 25 and 35 design gaps. Each gap was resolved with a design change and, where necessary, a confirming research.
Some examples:
no_storeforinternal-ingress: initiallytrue. The review highlighted that the wildcard*.tazlab.netcovers auth and dex — identity services. If the certificate is compromised, withoutno_store=falseit cannot be revoked. Changed tofalse.- Missing
allow_wildcard_certificates: mandatory parameter for issuing wildcards. Without it, Vault rejects the request with error 400. Not in the initial design. - Separation of
db-client-*roles: from a singledatabase-clientrole to four separate roles to prevent escalation: each PostgreSQL user has its own Vault role, withallowed_domainsscoped to its username.
The Implementation: One Day, Five Phases#
With the design approved and research completed, the implementation was surprisingly fast: about 12 hours of work. But each phase brought its challenges — and some were solved only thanks to the researches done in previous days.
Phase 0: Backup and Preparation#
Before touching any configuration, I prepared the rollback point:
- Snapshots of the Talos VMs (CP and Worker) on Proxmox, via API token (VM.Snapshot permission added to the TerraformAdmin role after a quick
pveumsession) - Vault Raft snapshot to S3 with the root token extracted from
init.json(the one inroot-token.txthad expired — discovery made on the spot) - Git tag
pre-pki-buildon all three repositories involved
Phase 1: Terraform — JWT Roles, PKI Policies, vso-system Namespace#
The Terraform code for JWT roles and PKI policies was already written during the design phase. I extended the vault-jwt-config module with eight PKI policies and eight JWT roles using bound_claims glob on the sub claim. In parallel, I added the vso-system namespace and the vault-ca-cert secret to the k8s-engine module, and created eight VaultAuth resources in tazlab-k8s for per-namespace segregation.
Here I hit the first snag: JWT roles cannot use bound_claims on the kubernetes.io/serviceaccount/namespace claim because the JWT issued by Talos does not include this claim. The existing vso-role-jwt role already used the correct pattern with bound_service_account_names, but I hadn’t noticed until reviewing the VSO log.
Phase 2: The PKI Engine in Vault#
Enabling the PKI engine was quick: mount pki_root with import of the First Intermediate CA, mount pki_int with CSR generation, signing via pki_root/root/sign-intermediate, and set-signed with the full chain (Tier 2 + Tier 1 + Tier 0). But two problems needed attention.
Issue #17359 — The Phantom Default Issuer. After set-signed, the pki_int/issue/* endpoint returned an empty ca_chain. Certificates were issued, but without the intermediate chain — and without the chain, Go clients reject the certificate with tls: unknown certificate authority. The cause is the multi-issuer engine introduced in Vault 1.11. I had to create a new issuer with the full chain and set it as default:
vault write pki_int/root/replace default=<new-issuer-id>Issue #16667 — The Phantom EC Parameters. The offline CA’s PEM bundle contained -----BEGIN EC PARAMETERS----- blocks that Go’s ASN.1 parser does not tolerate. Removed manually with a regex.
Then I created the eight roles on pki_int: internal-ingress (no_store=false, allow_wildcard_certificates=true, TTL 168h), cluster-local-mtls, database-server, four db-client-* roles for each PostgreSQL user, and cluster-replication for intra-cluster replication. Each role was configured with the parameters that emerged from the researches: signature_bits=256 for clients, exclude_cn_from_sans not supported (parameter ignored by Vault), allow_bare_domains=true for CN usernames.
Phase 3: Let’s Encrypt for Blog and Wiki#
Blog and wiki were using the static TLS wildcard, obtained manually months ago with lego and Cloudflare DNS-01 — no automatic renewal. I created two Certificate resources in cert-manager: blog-tazlab.net-tls for four domains (blog.tazlab.net, tazlab.net, www, lab) and wiki-tazlab.net-tls for wiki.tazlab.net. Both with ClusterIssuer letsencrypt-issuer and HTTP01 solver. Active in seconds.
Then I updated the Ingresses. The change was simple, but the first push broke an Ingress’s YAML syntax (missing rules section under spec). Lesson learned: always check the diff before pushing.
Phase 4: The Wildcard Cutover#
The critical moment: replacing the static wildcard with the VaultPKISecret. I created vault-pki-tls in vso-system with commonName *.tazlab.net, TTL 168h, and reflector annotations for auth and dex.
Then I migrated Ingresses one by one: first auth, verified, then dex. oauth2-proxy needed attention: it had to trust the Vault PKI certificate to talk to Dex over HTTPS. I added --provider-ca-file=/etc/oauth2-proxy/pki/ca.crt with a secret mount via items: (subPath projection is broken on Talos due to Kubernetes symlinks — “not a directory” error).
The last step: deleting the wildcard-tls ExternalSecret from infrastructure/tls/. One line removed, months of technical debt closed.
Phase 5: mTLS on PostgreSQL — Where Everything Got Complex#
The server-side TLS was simple: customTLSSecret on PGO with the VaultPKISecret tazlab-db-server-tls. But PGO ignores ca.crt from the Secret unless declared with explicit items:. And PostgreSQL’s ssl_ca_file — the file PostgreSQL uses to verify client certificates — is immutable in PGO: it cannot be overridden via patroni.dynamicConfiguration. The only channel is the ca.crt bundle in the Secret.
The CA to put in that bundle couldn’t be just the offline Root CA. It needed the full intermediate chain, because PGO generates the server cert from customTLSSecret but uses its own internal CA for replication. I built a combined CA bundle: PGO internal CA + Tier 2 + Tier 1 + Tier 0 = 4 certificates in a single ca.crt file in the Secret.
Then I added the pg_hba rule: hostssl grafana grafana 0.0.0.0/0 cert. And here I discovered that the database name must be exact: if the rule says database=grafana and the client connects to database=postgres, the rule doesn’t match and PostgreSQL falls back to md5 asking for a password. A subtlety that cost me 20 minutes.
Grafana was the longest challenge of the project. The kube-prometheus-stack chart doesn’t propagate configurations the way you’d expect:
grafana.commandis not supported — the Grafana sub-chart completely ignores thecommandfield. I discovered this after trying to use it to copy certificates. Solution:extraInitContainers.grafana.envis filtered byassertNoLeakedSecrets— the chart’s security mechanism blocks variables containing sensitive-looking strings. Disabled withassertNoLeakedSecrets: false.extraSecretMountsworks — but requiresdefaultMode: 384(0600 in octal). Grafana’s libpq library rejects the private key if permissions are too permissive.extraVolumeshas a known bug — sometimes mounts emptyDir instead of the expected resource. Workaround: an init container that copies from the Secret mount to an emptyDir, usingcp -HLfor symlinks.containerSecurityContext.runAsUsermust be 472 (Grafana’s UID), not 1000 as I initially had — otherwise Grafana can’t read the certificate files.
The final configuration that worked after 5 attempts and a dedicated research:
grafana:
database:
type: sqlite3
env:
GF_DATABASE_TYPE: "postgres"
GF_DATABASE_HOST: "tazlab-db-primary.tazlab-db.svc:5432"
GF_DATABASE_NAME: "grafana"
GF_DATABASE_USER: "grafana"
GF_DATABASE_SSL_MODE: "require"
extraSecretMounts:
- name: grafana-db-certs
secretName: db-client-grafana-tls
mountPath: /tmp/db-certs-in
defaultMode: 384
extraInitContainers:
- name: copy-db-certs
image: alpine:3.21
command:
- sh -c "cp -rL /tmp/db-certs-in/* /etc/grafana/certs/ && chmod 600 /etc/grafana/certs/tls.key"
containerSecurityContext:
runAsUser: 472
runAsGroup: 472The result: Grafana 3/3, "database":"ok", and pg_stat_ssl shows usename=grafana, ssl=t. Client certificate authentication working.
pgAdmin and mnemosyne: postponed. pgAdmin 4 doesn’t support client certificates via environment variables — it requires a servers.json file. The Go mnemosyne app needs code changes to handle the full client certificate chain (Go requires leaf + intermediates in the sslcert file, unlike psql which only accepts the leaf). For now they continue with passwords.
Technical Debts#
- DNS Tailscale post-reboot: after node reboots, Vault’s MagicDNS takes 1-5 minutes to resolve correctly. ESO’s ClusterSecretStore reports it as an error, blocking Flux. Fixed by increasing the Flux timeout from 5 to 10 minutes. Doesn’t happen on destroy+create.
- mTLS for pgAdmin and mnemosyne: documented, planned, not yet implemented. Requires
servers.jsonfor pgAdmin, Go code changes for mnemosyne. - TrustedRootConfigs not active: the patch is persisted in Talos configuration, but takes effect only after the next reboot. On destroy+create it will be integrated into Terraform
config_patches.
Lessons Learned#
CRISP Design Paid Off#
Every problem encountered during implementation was quickly solvable because the underlying architecture was solid. If I had started writing code without the research and review phase, I would have had to revise the architecture multiple times — instead I could focus on operational details.
Research Is an Investment, Not a Cost#
Each research saved me hours of trial and error. Without the research on PGO ssl_ca_file, I would have spent hours trying to override a parameter that PGO deliberately makes immutable. Without the Grafana research, I’d still be wondering why grafana.command is ignored.
mTLS Is Not Standardized Across Apps#
A client certificate is a file. The way each application loads it is not. psql picks it up from environment variables. Grafana from specific env vars (with names that change depending on which docs you read). pgAdmin from a JSON file. Go from a connection string. Each has different requirements for format (leaf only vs bundle), permissions (0600 required for libpq), and path.
Overkill? Yes, Deliberately#
A three-tier PKI for a two-node cluster is objectively excessive. But that was never the point. The point was to prove that a homelab can host the same architectures that run in enterprise datacenters. With the right tools, the right methodology, and the patience to do things properly.
What’s Next#
The next step is a destroy+create cycle — start the cluster from scratch and verify that the entire PKI rebuilds without manual interventions. A true one-shot test. Then pgAdmin and mnemosyne on mTLS. Then, finally, the project that made all of this necessary: the cross-site PostgreSQL database with automatic failover, which I’ve unblocked with this PKI and which awaits me.
But that’s another story.
This article is part of a series on TazLab infrastructure management. Previous articles: CRISP 2.0: Research Required, Verified Plan, Zero Assumptions, First Steps Toward Dynamic Secrets, The Research That Killed the Injector. Code on github.com/tazzo.


