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 isdannihome.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_ADDRis 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:
commonNameis 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
| Symptom | Cause | Fix |
|---|---|---|
no such file or directory: ca.crt on Vault config | Vault is external, can’t read pod SA path | Pass kubernetes_ca_cert=@k8s-ca.crt explicitly |
tls: certificate signed by unknown authority in ClusterIssuer | cert-manager doesn’t trust Vault’s cert | Add caBundle with Caddy CA to the ClusterIssuer |
403 permission denied on Kubernetes login | Token issuer URL unreachable from Vault | Use token_reviewer_jwt with a dedicated ServiceAccount |
common_name field is required from Vault | CSR has no CN | Add 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.