This post documents how to set up a proper PKI for a homelab environment by importing an existing Caddy CA into HashiCorp Vault, and wiring it up with cert-manager on Kubernetes so that services automatically get trusted TLS certificates.

Overview

The trust chain we’re building:

Caddy Root CA  →  Vault PKI  →  cert-manager  →  Kubernetes Secret (tls.crt / tls.key)

Assumptions:

  • Caddy is running as a reverse proxy and already has an internal CA
  • Caddy is running outside the Kubernetes cluster on a dedicated host
  • Vault is running outside the Kubernetes cluster (e.g. in Docker)
  • cert-manager is installed in the Kubernetes cluster
  • Internal domain is fritz.box, my public domain is dannihome.de

Step 1: Locate Caddy’s CA Certificate and Key

Caddy stores its internal CA here by default:

~/.local/share/caddy/pki/authorities/local/root.crt
~/.local/share/caddy/pki/authorities/local/root.key

In my dockerized setup it resides inside the Caddy data volume under /data/caddy/pki/authorities/local/.


Step 2: Set Up Vault PKI Secrets Engine

Enable the PKI secrets engine and set a maximum TTL:

vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki

Step 3: Import Caddy’s CA into Vault

Combine the cert and key into a single bundle and import it:

cat root.crt root.key > caddy-bundle.pem

vault write pki/config/ca pem_bundle=@caddy-bundle.pem

Hint: Run this from your laptop where the bundle file lives. As long as VAULT_ADDR is set and you are authenticated (vault login), the @ syntax reads the file locally.


Step 4: Configure AIA URLs

Suppress the AIA warning and allow clients to find CRL and issuer information:

vault write pki/config/urls \
  issuing_certificates="https://vault.fritz.box/v1/pki/ca" \
  crl_distribution_points="https://vault.fritz.box/v1/pki/crl"

Step 5: Create a PKI Role

Define which domains Vault is allowed to issue certificates for:

vault write pki/roles/homelab \
  allowed_domains="dannihome.de,fritz.box" \
  allow_subdomains=true \
  max_ttl=8760h

Step 6: Test the Setup with OpenSSL

Issue a test certificate and verify it chains back to the Caddy CA:

vault write -field=certificate pki/issue/homelab \
  common_name="test.fritz.box" \
  ttl=24h > test.crt

# Inspect the certificate
openssl x509 -in test.crt -text -noout

# Verify the chain
openssl verify -CAfile root.crt test.crt
# Expected: test.crt: OK

(root.crt being the Caddy root CA cert)


Step 7: Enable Kubernetes Auth in Vault

Since Vault runs outside the cluster, provide the Kubernetes CA explicitly:

# Extract the cluster CA cert
kubectl config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > k8s-ca.crt

vault auth enable kubernetes

vault write auth/kubernetes/config \
  kubernetes_host="https://<k8s-api-ip>:6443" \
  kubernetes_ca_cert=@k8s-ca.crt

Step 8: Create a ServiceAccount for Vault Token Review

Because Vault is external, it cannot do OIDC discovery against the cluster’s internal issuer URL. Instead, use the Kubernetes TokenReview API with a dedicated ServiceAccount:

kubectl -n cert-manager create serviceaccount vault-auth

kubectl apply -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
  name: vault-auth-token
  namespace: cert-manager
  annotations:
    kubernetes.io/service-account.name: vault-auth
type: kubernetes.io/service-account-token
EOF

kubectl apply -f - <<EOF
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: vault-auth-tokenreview
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:auth-delegator
subjects:
  - kind: ServiceAccount
    name: vault-auth
    namespace: cert-manager
EOF

Extract the token and update Vault’s Kubernetes auth config:

TOKEN=$(kubectl -n cert-manager get secret vault-auth-token -o jsonpath='{.data.token}' | base64 -d)

vault write auth/kubernetes/config \
  kubernetes_host="https://<k8s-api-ip>:6443" \
  kubernetes_ca_cert=@k8s-ca.crt \
  token_reviewer_jwt="$TOKEN"

Step 9: Create a Vault Policy and Role for cert-manager

vault policy write cert-manager - <<EOF
path "pki/sign/homelab" {
  capabilities = ["create", "update"]
}
EOF

vault write auth/kubernetes/role/cert-manager \
  bound_service_account_names=cert-manager \
  bound_service_account_namespaces=cert-manager \
  policies=cert-manager \
  ttl=1h

Step 10: Create the cert-manager ClusterIssuer

cert-manager inside the cluster needs to trust Vault’s TLS certificate (which is signed by the Caddy CA). Provide the CA as a base64-encoded caBundle:

base64 -w 0 root.crt
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: vault-issuer
spec:
  vault:
    server: https://vault.fritz.box
    path: pki/sign/homelab
    caBundle: <base64-encoded-root.crt>
    auth:
      kubernetes:
        role: cert-manager
        serviceAccountRef:
          name: cert-manager

Step 11: Request a Certificate

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: myapp-tls
  namespace: myapp-namespace
spec:
  secretName: myapp-tls-secret
  commonName: myapp.fritz.box
  issuerRef:
    name: vault-issuer
    kind: ClusterIssuer
  dnsNames:
    - myapp.fritz.box

Important: commonName is required — Vault’s PKI role enforces it by default.

cert-manager will store the issued certificate in the Secret myapp-tls-secret containing ca.crt, tls.crt, and tls.key, and will automatically renew it before expiry.


Step 12: Wire it into an Ingress

You can either reference the Secret directly or use the annotation to let cert-manager create the Certificate resource automatically:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp
  namespace: myapp-namespace
  annotations:
    cert-manager.io/cluster-issuer: vault-issuer
spec:
  tls:
    - hosts:
        - myapp.fritz.box
      secretName: myapp-tls-secret
  rules:
    - host: myapp.fritz.box
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp
                port:
                  number: 8080

Troubleshooting

SymptomCauseFix
no such file or directory: ca.crt on Vault configVault is external, can’t read pod SA pathPass kubernetes_ca_cert=@k8s-ca.crt explicitly
tls: certificate signed by unknown authority in ClusterIssuercert-manager doesn’t trust Vault’s certAdd caBundle with Caddy CA to the ClusterIssuer
403 permission denied on Kubernetes loginToken issuer URL unreachable from VaultUse token_reviewer_jwt with a dedicated ServiceAccount
common_name field is required from VaultCSR has no CNAdd commonName to the Certificate resource

Result

Once everything is in place, any workload in the cluster can get a trusted TLS certificate by simply referencing the ClusterIssuer. The full chain — Caddy Root CA → Vault PKI → cert-manager → Kubernetes Secret — is operational.