Home
Vault Enterprise + Kubernetes Lab
What you'll build
A 3-node Vault Enterprise HA cluster on Minikube (via Podman), then three pathways into it: Agent Injector, Vault Secrets Operator, and cert-manager.
How to use this guide
Work through the phases in order — later steps assume the ServiceAccount, role, and test secret created in Phase 2 already exist.
Search the guide
Search jumps straight to the matching step. Each phase ends with its own troubleshooting table — check there first.

Check these are installed. If not, install via Homebrew.

bash
brew install podman minikube kubectl helm

Verify versions:

bash
podman --version
minikube version
kubectl version --client
helm version

You don’t need exact versions matched — anything reasonably recent (Podman 5.x, minikube 1.33+, kubectl 1.29+, Helm 3.x) works fine for this lab.


Step 1.0 — Check your actual physical RAM before setting machine memory

Podman’s memory cap on macOS isn’t arbitrary — it won’t let you allocate more RAM to the VM than the host physically has, minus a reserve for macOS itself. If Podman refuses your requested --memory value and reports a lower cap instead (a real example from testing this guide: requesting 8192 MB on an 8GB Mac got capped to 7904 MB), check what you’re actually working with:

bash
sysctl hw.memsize

This prints bytes. Divide by 1,048,576 to get MB — e.g. 8589934592 = 8192 MB total.

  • If the cap Podman reports is within a few hundred MB of your total physical RAM: that’s the real ceiling, not a bug — macOS needs headroom too. A 3-node HA Vault cluster plus Minikube’s own overhead will be tight but workable on 8GB if you keep resource requests low (this guide does). On more RAM, you’ll have more comfortable margin.
  • If Podman’s cap is far below your total physical RAM (e.g. you have 16GB+ but it’s still capping around 8GB): the machine was likely initialized once with a low value and Podman is holding onto that. Destroy and recreate it explicitly (Step 1.1 below).

Step 1.1 — Initialize the Podman machine

Podman on macOS isn’t native — it runs a lightweight Linux VM under the hood (via qemu on Apple hardware). You need that VM running before minikube can use podman as a driver.

If you already have a machine from before and need to resize it:

bash
podman machine stop
podman machine rm

Then initialize with an explicit memory value based on what Step 1.0 told you — leave roughly 500MB–1GB of your total physical RAM for macOS, and use the rest:

bash
podman machine init --cpus 4 --memory <YOUR_VALUE> --disk-size 40

Concretely: on an 8GB Mac, that’s around 70007900. On 16GB, around 1400015000. Don’t copy a number from someone else’s run — compute it from your own sysctl hw.memsize output.

Step 1.2 — Set the machine to rootful mode

This is the step almost everyone misses, and it’s the #1 cause of minikube+podman failures on macOS.

By default, newer Podman versions create the machine in rootless mode. Minikube’s podman driver needs rootful — it requires privileges to manage networking and cgroups that rootless mode restricts.

bash
podman machine set --rootful

Step 1.3 — Start the machine

bash
podman machine start

Verify it’s actually rootful:

bash
podman machine list

You should see a * next to your default machine and it should show as running. Then confirm rootful mode:

bash
podman info --format '{{.Host.Security.Rootless}}'

This should print false. If it prints true, Step 1.2 didn’t take — stop the machine (podman machine stop), re-run Step 1.2, and start it again.

Step 1.4 — Sanity check Podman itself

bash
podman run --rm hello-world

If this pulls the image and prints the “Hello from Docker!” (yes, that’s the actual message, it’s the same test image) confirmation, Podman is working correctly. Don’t proceed to minikube until this succeeds.

Step 1.5 — Start minikube

bash
minikube start --driver=podman --container-runtime=containerd --cpus=4 --memory=<VALUE>

Leave the podman machine’s own overhead some room — set this to roughly 800MB–1GB below the --memory value you gave podman machine init in Step 1.1, not equal to it. E.g. if your machine got 7900 MB, use --memory=7000 here. That headroom covers the VM’s own processes (podman’s API layer, networking, etc.), which matters once you’re running a 3-node Vault cluster instead of a single pod.

Two flags worth understanding, not just copying:

  • --driver=podman — tells minikube to create its “node” as a Podman container instead of a VM or Docker container.
  • --container-runtime=containerd — this is the container runtime running inside the minikube node itself (a separate concern from the driver). Use containerd here rather than the default docker — running a Docker daemon nested inside a Podman container has historically been flaky. containerd avoids that nesting problem entirely.

This will take a few minutes on first run (pulling the minikube node image).

Step 1.6 — Verify the cluster

bash
kubectl get nodes

Expected output: one node, status Ready.

bash
kubectl get pods -A

Expected: a handful of system pods (coredns, kube-proxy, storage-provisioner, etc.) all Running.

If you want a visual, optionally:

bash
minikube dashboard

This opens a browser-based cluster view. Not required for anything downstream — skip if you’d rather stay on the CLI.

Phase 1 troubleshooting

Symptom Cause Fix
minikube start hangs or errors on network setup Podman machine is rootless Redo Step 1.2, confirm with Step 1.3’s check
podman run hello-world fails with permission errors Machine not started, or stale rootless state podman machine stop && podman machine set --rootful && podman machine start
minikube node stuck NotReady Resource starvation Increase --cpus/--memory in Step 1.5, or bump the podman machine’s allocation and recreate it
Error: exit status 125 from podman Usually a stale container from a prior failed attempt minikube delete, then re-run Step 1.5
Podman won’t allocate the --memory value you asked for Host physical RAM ceiling (see Step 1.0) If your Mac genuinely has more RAM than the cap suggests, podman machine stop && podman machine rm, then re-init with a higher explicit --memory value computed from sysctl hw.memsize

This replaces dev mode with an actual 3-node cluster: unseal keys, root token generated at init, Raft integrated storage, nodes joining as peers, one active node and two standbys — the real operational shape, not the single-binary shortcut. The goal is still the same trust chain as before (ServiceAccount token → Vault token, scoped by policy), but now you’re building it against infrastructure that behaves like the real thing, including a step you can’t skip in dev mode: initializing and unsealing.

Step 2.1 — Add the HashiCorp Helm repo

bash
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

Step 2.2 — Get your Enterprise license and stage it as a Secret

You’ll have a license file (.hclic) or a license string from HashiCorp. Create the namespace and store it as a Kubernetes Secret — the Helm chart references this to activate Enterprise features on boot.

bash
kubectl create namespace vault

kubectl create secret generic vault-ent-license \
  --namespace vault \
  --from-literal=license="$(cat /path/to/your/vault.hclic)"

Verify the secret actually got real content before moving on. If the path above is wrong, cat fails but the shell doesn’t stop — you’ll end up with a secret that “created successfully” but is empty, and the failure won’t surface until Vault refuses the license much later. Check now:

bash
kubectl get secret vault-ent-license -n vault -o jsonpath='{.data.license}' | base64 -d | head -c 50

This should print the start of your actual license content. If it prints nothing, fix the path and recreate the secret before continuing.

Check what modules your license actually includes before going further. If you’re requesting this from an internal HashiCorp licensing portal, make sure you get a standard Platform Standard license, not one with a pki-only add-on attached. A pki-only license locks the entire cluster into PKI-only mode — every secrets engine except PKI gets rejected with a 400, which will break the KV setup in Step 2.9 and everything in Phase 3A/3B. You can check after the fact with vault license inspect, but it’s faster to just confirm at request time.

Step 2.3 — Install Vault in HA mode with Raft storage

Before running this: check the Vault Enterprise Docker Hub tags for the current version. 1.17.6-ent below is a placeholder from when this guide was written — using a stale tag isn’t fatal, but you’ll want to know what version you’re actually running rather than inherit one by accident.

bash
helm install vault hashicorp/vault \
  --namespace vault \
  --set 'server.image.repository=hashicorp/vault-enterprise' \
  --set 'server.image.tag=1.17.6-ent' \
  --set 'server.enterpriseLicense.secretName=vault-ent-license' \
  --set 'server.ha.enabled=true' \
  --set 'server.ha.replicas=3' \
  --set 'server.ha.raft.enabled=true' \
  --set 'server.ha.raft.setNodeId=true' \
  --set 'server.affinity=' \
  --set 'injector.affinity='

The server.affinity= line matters more than it looks. The chart ships a hard pod anti-affinity rule by default: it refuses to schedule two Vault server pods on the same Kubernetes node, which is correct behavior for a real multi-node cluster. Minikube is single-node. Without this override, vault-0 schedules fine (first pod, nothing to conflict with), and vault-1/vault-2 sit in Pending forever — you’ll see pod does not have a host assigned if you try to exec into them. Setting server.affinity= to empty removes the rule entirely, which is the right call for a single-node lab and the wrong call for production.

injector.affinity= is the same fix applied to the Agent Injector deployment, which ships the identical default rule. It’s easy to miss because the injector runs as a single replica — the rule only bites when Kubernetes needs to schedule a second injector pod alongside the first, which doesn’t happen on day one. It happens the first time the injector pod gets replaced (a kubectl rollout restart, a helm upgrade, a node reboot, or recovering from the TLS issue in the Phase 3A troubleshooting table below): the new pod can’t schedule while the old one holds the node, the old one won’t terminate until the new one is ready, and you get a permanent Pending deadlock. Setting it now avoids ever hitting that.

What changed from dev mode, mechanically: no more single ephemeral pod. This creates a StatefulSet — vault-0, vault-1, vault-2 — each with its own persistent volume claim (backed by minikube’s default storage-provisioner addon, already active from Phase 1). None of them are usable yet.

Step 2.4 — Watch the pods come up sealed

bash
kubectl get pods -n vault

Expected: all three pods Running but showing 0/1 ready. That 0/1 is correct and expected — a sealed, uninitialized Vault fails its readiness probe on purpose. Don’t troubleshoot this; it’s the normal starting state.

Step 2.5 — Initialize vault-0

This is the step that doesn’t exist in dev mode. Initializing generates the root key, splits it into unseal key shares, and issues the initial root token — this only happens once, against exactly one node.

bash
kubectl exec -n vault -it vault-0 -- vault operator init -key-shares=1 -key-threshold=1

-key-shares=1 -key-threshold=1 is a lab simplification — one unseal key, no splitting. Never do this in production; real deployments use -key-shares=5 -key-threshold=3 (or similar) so no single person holds the whole key. For this lab, it just makes the walkthrough shorter.

Save the two values this prints — you’ll use both repeatedly:

  • Unseal Key 1
  • Initial Root Token

Step 2.6 — Unseal vault-0

bash
kubectl exec -n vault -it vault-0 -- vault operator unseal <unseal-key-from-2.5>

Confirm:

bash
kubectl exec -n vault -it vault-0 -- vault status

Expected: Sealed: false, HA Mode: active. vault-0 is now your cluster leader.

Step 2.7 — Join vault-1 and vault-2 as Raft peers

Each standby has to explicitly join the Raft cluster, then get unsealed with the same unseal key from Step 2.5 (the key material is shared across the cluster once a node joins — it’s not a separate key per node).

bash
kubectl exec -n vault -it vault-1 -- vault operator raft join http://vault-0.vault-internal:8200
kubectl exec -n vault -it vault-1 -- vault operator unseal <unseal-key-from-2.5>

kubectl exec -n vault -it vault-2 -- vault operator raft join http://vault-0.vault-internal:8200
kubectl exec -n vault -it vault-2 -- vault operator unseal <unseal-key-from-2.5>

vault-0.vault-internal is the per-pod DNS name the chart’s headless service provides — this is how Raft peers find each other inside the cluster.

Step 2.8 — Verify the cluster is healthy

bash
kubectl get pods -n vault

Expected: all three pods 1/1 Running.

Log in so your CLI session is authenticated (this writes a token file inside the pod’s filesystem, so it persists across separate kubectl exec calls for the life of the pod):

bash
kubectl exec -n vault -it vault-0 -- vault login <root-token-from-2.5>

Check the Raft peer set:

bash
kubectl exec -n vault -it vault-0 -- vault operator raft list-peers

Expected: three rows, one leader and two follower/voters, all true under voter.

Step 2.9 — Enable the KV v2 secrets engine

Unlike dev mode, a freshly initialized Vault has nothing mounted at secret/ — you have to enable it explicitly:

bash
kubectl exec -n vault -it vault-0 -- vault secrets enable -path=secret kv-v2

Step 2.10 — Enable the Kubernetes auth method

bash
kubectl exec -n vault -it vault-0 -- vault auth enable kubernetes

Step 2.11 — Configure the Kubernetes auth method

This tells Vault how to reach the Kubernetes API to validate ServiceAccount tokens it’s handed.

bash
kubectl exec -n vault -it vault-0 -- sh -c \
  'vault write auth/kubernetes/config \
    kubernetes_host="https://${KUBERNETES_PORT_443_TCP_ADDR}:443"'

KUBERNETES_PORT_443_TCP_ADDR is an environment variable Kubernetes automatically injects into every pod — it points at the internal kubernetes API service. You didn’t set it; it’s just there.

Note what you’re not doing: manually passing a CA cert or a reviewer JWT. Vault 1.9+ auto-detects both from its own pod’s mounted ServiceAccount, since that pod already has a token and CA cert available at /var/run/secrets/kubernetes.io/serviceaccount/. You only override this if Vault needs to validate tokens using a different ServiceAccount than its own — not needed here.

Step 2.12 — Write a test secret

bash
kubectl exec -n vault -it vault-0 -- vault kv put secret/myapp/config username=demo password=demo123

Step 2.13 — Write a policy scoped to that secret path

bash
kubectl exec -n vault -it vault-0 -- sh -c 'vault policy write myapp-read - <<EOF
path "secret/data/myapp/*" {
  capabilities = ["read"]
}
EOF'

Note the path: KV v2 prefixes actual data paths with data/ internally (metadata, versioning, etc. live alongside it) — this trips people up constantly. secret/myapp/config (what you wrote to) becomes secret/data/myapp/config (what you grant policy access to).

Step 2.14 — Create a Kubernetes auth role

This is the binding: which ServiceAccount, in which namespace, gets which policy.

bash
kubectl create serviceaccount myapp-sa -n default

kubectl exec -n vault -it vault-0 -- vault write auth/kubernetes/role/myapp-role \
  bound_service_account_names=myapp-sa \
  bound_service_account_namespaces=default \
  policies=myapp-read \
  ttl=1h

Step 2.15 — Prove the trust chain manually

This is the step worth not skipping — before Injector or VSO ever wraps this in automation, do it by hand once so the mechanics are real to you.

One HA-specific thing to notice as you do this: the vault Kubernetes Service load-balances across all three pods, not just the active one. If your request happens to land on vault-1 or vault-2 (a standby), Vault transparently forwards it to the active node and returns the result — this is core HA request forwarding, not something you configured. You don’t need to target vault-0 specifically for reads/writes through the Service; you only had to target it directly in Steps 2.5–2.7 because init/unseal/raft join are node-specific operations by nature.

Spin up a throwaway pod using that ServiceAccount:

bash
kubectl run vault-test -n default \
  --image=curlimages/curl \
  --overrides='{"apiVersion":"v1","kind":"Pod","spec":{"serviceAccountName":"myapp-sa"}}' \
  --command -- sleep 3600

Exec into it:

bash
kubectl exec -it vault-test -n default -- sh

Inside that shell, read the pod’s own ServiceAccount token and use it to log in to Vault:

sh
KUBE_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)

curl --request POST \
  --data "{\"jwt\": \"$KUBE_TOKEN\", \"role\": \"myapp-role\"}" \
  http://vault.vault.svc.cluster.local:8200/v1/auth/kubernetes/login

Expected result: a JSON response containing auth.client_token — a Vault token, scoped to the myapp-read policy, issued purely because this pod’s identity matched the role you configured. No credentials were typed anywhere.

Take that token and read the secret with it:

sh
VAULT_TOKEN="<client_token from above>"

curl --header "X-Vault-Token: $VAULT_TOKEN" \
  http://vault.vault.svc.cluster.local:8200/v1/secret/data/myapp/config

Expected: JSON containing username: demo, password: demo123.

Exit the pod and clean it up:

bash
exit
kubectl delete pod vault-test -n default

Step 2.16 — Enable the Vault UI (optional)

bash
helm upgrade vault hashicorp/vault \
  --namespace vault \
  --reuse-values \
  --set 'ui.enabled=true' \
  --set 'server.affinity=' \
  --server-side=false

Two things about this command worth understanding rather than just running:

  • --server-side=false avoids the MutatingWebhookConfiguration caBundle ownership conflict covered in the troubleshooting table below — Helm’s server-side apply collides with the injector controller’s own management of that field on any upgrade to this release.
  • --set 'server.affinity=' is included again here, deliberately. If you fixed stuck Pending pods earlier via a direct kubectl patch statefulset (rather than a successful helm upgrade), that fix only lives on the live StatefulSet object — it was never recorded in Helm’s stored release values. Any later helm upgrade --reuse-values regenerates the manifest from those stale values and will silently reintroduce the anti-affinity rule, undoing your earlier fix. Keep this flag on every helm upgrade for this release until you’ve confirmed it’s actually stored (helm get values vault -n vault should show it).

ui.enabled is baked into the server config, which is only read at container startup, so recreate all three pods:

bash
kubectl delete pod vault-0 vault-1 vault-2 -n vault
kubectl get pods -n vault -w

Once all three show 0/1 Running (sealed, not Pending), unseal and re-login vault-0, then re-check vault-1/vault-2 (raft list-peers — they generally don’t need to rejoin, just unseal again).

Reach the UI from your Mac via port-forward — minikube’s podman driver doesn’t give clean direct access to the node IP from the host:

bash
kubectl port-forward -n vault svc/vault 8200:8200

Leave that running and open http://127.0.0.1:8200/ui in a browser. Log in with your root token. The port-forward only lives as long as that terminal stays open.

Everything the Agent Injector and Vault Secrets Operator do in Phase 3 is this exact exchange, automated. Injector does it via a sidecar that intercepts pod startup; VSO does it via a controller watching CRDs and syncing the result into a Kubernetes Secret. Different delivery mechanisms, identical trust chain underneath — which is exactly the “which one should I use” distinction that comes up in customer conversations.

Phase 2 troubleshooting

Symptom Cause Fix
vault status hangs Pod not fully ready yet Wait, re-check kubectl get pods -n vault
permission denied on the curl secret read Policy path mismatch (missing data/) Re-check Step 2.13’s path syntax
Kubernetes auth login returns service account not found or similar Wrong namespace/SA bound in the role Re-check Step 2.14’s bound_service_account_namespaces matches where you ran the test pod
curl: (6) Could not resolve host DNS between namespaces isn’t the issue — check the service name kubectl get svc -n vault to confirm the Vault service is actually named vault
Pods stuck 0/1 Running after Step 2.7 Sealed — this is normal until you unseal each node Re-run Step 2.6/2.7’s unseal commands with the correct key
vault operator raft join fails with a connection error vault-0 not yet unsealed/active, or DNS not ready Confirm Step 2.6 completed (HA Mode: active on vault-0) before joining peers
license is expired or Enterprise features refuse to activate License secret missing/malformed, or trial expired Re-check Step 2.2 — kubectl describe secret vault-ent-license -n vault to confirm the key exists; contact your HashiCorp rep if the trial itself expired
Pod evicted or OOMKilled Podman machine memory ceiling (see Phase 1, Step 1.0) too tight for 3 replicas Lower per-pod resource requests via --set server.resources... on the Helm install, or free up host RAM
vault-1/vault-2 stuck Pending, kubectl exec says “pod does not have a host assigned” Default hard pod anti-affinity — single-node minikube can’t satisfy it See the dedicated walkthrough below — this one has a subtlety worth reading, not just a one-liner
helm upgrade fails with a MutatingWebhookConfiguration caBundle conflict The injector controller patches its own webhook’s caBundle at runtime, so Helm’s server-side-apply collides with it on any upgrade to this release, not just this one Add --server-side=false to the helm upgrade command. If your Helm version still complains about --force vs --force-replace, drop --force entirely — --server-side=false alone usually resolves it
Anti-affinity Pending issue comes back after an unrelated helm upgrade (e.g. enabling the UI) You fixed it earlier via direct kubectl patch statefulset, which never updated Helm’s stored release values — --reuse-values on a later upgrade regenerates the manifest from the stale values and reintroduces the rule Include --set 'server.affinity=' explicitly on every helm upgrade for this release until helm get values vault -n vault confirms it’s actually stored
secret/vault-ent-license created successfully but Vault still rejects the license or shows old modules $(cat /path) silently evaluated to an empty string because the file path was wrong — the shell doesn’t stop the command just because cat failed Verify before trusting it: kubectl get secret vault-ent-license -n vault -o jsonpath='{.data.license}' | base64 -d | head -c 100 should show real content, not nothing

Fixing stuck anti-affinity Pending pods, in full

If you’re following Step 2.3 as written above (with --set 'server.affinity=' included from the start), you shouldn’t hit this at all — it only bites if you installed before that line was added, or changed server.ha.replicas after the fact. If you do hit it:

1. Remove the anti-affinity rule from the StatefulSet’s pod template:

bash
kubectl patch statefulset vault -n vault \
  -p '{"spec":{"template":{"spec":{"affinity":null}}}}'

2. The subtlety: this only changes the template for pods created from now on. vault-0 was already created under the old template, and pod anti-affinity is symmetric — vault-0’s own still-active rule refuses to let any matching pod land on its node, which blocks vault-1/vault-2 from scheduling even after the patch. You have to recreate vault-0 too, not just the two stuck pods:

bash
kubectl delete pod vault-0 vault-1 vault-2 -n vault
kubectl get pods -n vault -w

vault-0’s data is safe (it reattaches to its existing PVC via Raft storage), but its seal state is in-memory only — it comes back sealed, and its ~/.vault-token session (on an emptyDir volume) is wiped. You’ll need to re-unseal and re-login before continuing:

bash
kubectl exec -n vault -it vault-0 -- vault operator unseal <original-unseal-key>
kubectl exec -n vault -it vault-0 -- vault login <original-root-token>

Once vault-0 is back and unsealed, vault-1/vault-2 should clear Pending on their own — then resume the raft-join/unseal steps (2.7) for both.


Assumes Phase 2 is done and working: a 3-node unsealed HA cluster running in vault namespace, kubernetes auth configured, myapp-role bound to myapp-sa/default, policy myapp-read scoped to secret/data/myapp/*, test secret at secret/myapp/config. Any vault exec commands in this phase assume you’re still logged in from Step 2.8 (root token session on vault-0) — if that pod restarted, re-run vault login <root-token> first.

Three separate pathways, three separate mechanisms. You’ll stand each one up and prove it independently.


Phase 3A: Vault Agent Injector

Step 3A.1 — Confirm it’s already running

The hashicorp/vault Helm chart installs the Injector by default alongside the server — you didn’t need to opt in. Confirm:

bash
kubectl get pods -n vault -l app.kubernetes.io/name=vault-agent-injector

Expected: one pod, Running, 1/1. If it’s not there, the chart was likely installed with injector.enabled=false somewhere — check with helm get values vault -n vault.

Step 3A.2 — Deploy a test workload with injection annotations

The Injector works via a mutating webhook: annotations on a pod spec tell it to add a Vault Agent sidecar that authenticates, fetches the secret, and writes it to a shared volume as a file.

bash
cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: injector-demo
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: injector-demo
  template:
    metadata:
      labels:
        app: injector-demo
      annotations:
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "myapp-role"
        vault.hashicorp.com/agent-inject-secret-config.txt: "secret/data/myapp/config"
    spec:
      serviceAccountName: myapp-sa
      containers:
      - name: app
        image: curlimages/curl
        command: ["sleep", "3600"]
EOF

Note what’s doing the work: the annotations, not a new controller you installed. vault.hashicorp.com/role maps directly to the myapp-role you created in Step 2.14. Nothing about auth changed — same ServiceAccount, same role, same policy.

Step 3A.3 — Verify the sidecar and the rendered secret

bash
kubectl get pods -n default -l app=injector-demo

Expected: 2/2 containers ready — your app container plus the injected vault-agent sidecar. If you’re still at 1/1 after a minute with no errors, see the Phase 3A troubleshooting table below — that’s a specific, silent failure mode, not a timing issue.

bash
kubectl exec -n default deploy/injector-demo -c app -- cat /vault/secrets/config.txt

Expected: the rendered secret data (username: demo, password: demo123 by default template format). The Agent wrote this file before your app container’s process even started — that’s the ordering guarantee the Injector gives you.

Clean up when done:

bash
kubectl delete deployment injector-demo -n default

Phase 3A troubleshooting

Symptom Cause Fix
Pod stays 1/1 instead of 2/2, no error anywhere — kubectl apply succeeds, pod runs fine, sidecar just never appears The webhook’s failurePolicy is Ignore by default. If the injector’s self-signed TLS cert has gone stale (check kubectl logs -n vault -l app.kubernetes.io/name=vault-agent-injector for remote error: tls: bad certificate / TLS handshake errors), the API server’s mutation call to the webhook fails silently and the pod is created unmutated instead of being rejected Confirm first with kubectl get pod -n default -l app=injector-demo -o jsonpath='{.items[0].metadata.annotations}' — if vault.hashicorp.com/agent-inject-status: injected is missing, mutation never ran. Then kubectl rollout restart deployment/vault-agent-injector -n vault and wait for kubectl get pods -n vault -l app.kubernetes.io/name=vault-agent-injector to show the new pod 1/1 Running — a pod that’s already running is never mutated retroactively, so re-checking the existing injector-demo pod will stay 1/1 forever no matter how long you watch it. Once the injector itself is ready, kubectl delete deployment injector-demo -n default and reapply the manifest. If the TLS error comes right back on that very next attempt, wait another ~30s (the webhook cert can take a moment to finish propagating after a restart) and retry the delete/reapply once more before assuming something deeper is wrong
The injector restart above hangs — new pod stuck Pending, old pod stuck Terminating, kubectl describe on the new one shows didn't match pod anti-affinity rules Chart’s default hard pod anti-affinity on the injector deployment, same class of issue as the Vault server pods in Phase 2 — deadlocks on single-node minikube since the new pod can’t schedule while the old one holds the only node Find it with kubectl get pods -n vault -l app.kubernetes.io/name=vault-agent-injector (it’s the one stuck Terminating — not your injector-demo workload in the default namespace) and delete it by the exact name shown, e.g. kubectl delete pod -n vault vault-agent-injector-7c9f9b6d4-abcde, to force it off the node immediately; the new one schedules right after. Add --set 'injector.affinity=' to your Helm install (see Step 2.3) so this doesn’t recur on future restarts

Phase 3B: Vault Secrets Operator (VSO)

Different model: instead of a sidecar rendering a file at pod startup, a cluster-wide controller watches CRDs and continuously syncs Vault data into native Kubernetes Secret objects — which any workload can then mount or reference normally, with no annotations or sidecar required.

Step 3B.1 — Install VSO

bash
helm install vault-secrets-operator hashicorp/vault-secrets-operator \
  --namespace vault-secrets-operator-system \
  --create-namespace

Verify:

bash
kubectl get pods -n vault-secrets-operator-system

Expected: the controller pod Running.

Step 3B.2 — Create a VaultConnection

Tells VSO where Vault lives.

bash
cat <<EOF | kubectl apply -f -
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultConnection
metadata:
  name: vault-connection
  namespace: default
spec:
  address: http://vault.vault.svc.cluster.local:8200
EOF

Step 3B.3 — Create a VaultAuth

Tells VSO how to authenticate — reusing the same Kubernetes auth mount and role from Phase 2.

bash
cat <<EOF | kubectl apply -f -
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
  name: vault-auth
  namespace: default
spec:
  method: kubernetes
  mount: kubernetes
  kubernetes:
    role: myapp-role
    serviceAccount: myapp-sa
  vaultConnectionRef: vault-connection
EOF

Same role, same ServiceAccount, same trust chain you proved by hand in Step 2.15 — VSO is just automating that curl exchange on a schedule.

Step 3B.4 — Create a VaultStaticSecret

This is the actual sync directive: read this Vault path, write it into this Kubernetes Secret, refresh on this interval.

bash
cat <<EOF | kubectl apply -f -
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
  name: myapp-config
  namespace: default
spec:
  vaultAuthRef: vault-auth
  mount: secret
  type: kv-v2
  path: myapp/config
  refreshAfter: 30s
  destination:
    name: myapp-config-secret
    create: true
EOF

Step 3B.5 — Verify the synced Secret

bash
kubectl get secret myapp-config-secret -n default -o jsonpath='{.data.username}' | base64 -d
echo
kubectl get secret myapp-config-secret -n default -o jsonpath='{.data.password}' | base64 -d

Expected: demo and demo123 — same values as the Injector pulled, but this time they landed in an ordinary Kubernetes Secret with no sidecar involved. Any pod can now reference myapp-config-secret with a standard envFrom/volumeMount, same as any other K8s Secret.

Prove it’s live-syncing: change the Vault value and watch it propagate without touching Kubernetes at all.

bash
kubectl exec -n vault -it vault-0 -- vault kv put secret/myapp/config username=demo password=updated456

Wait ~30s (your refreshAfter), then re-check:

bash
kubectl get secret myapp-config-secret -n default -o jsonpath='{.data.password}' | base64 -d

Expected: updated456. This is the core difference from Injector — VSO keeps syncing on an interval; Injector renders once at pod startup (or on a template-configured interval of its own, but it’s sidecar-local, not a cluster-wide Secret object).

Clean up when done:

bash
kubectl delete vaultstaticsecret myapp-config -n default
kubectl delete secret myapp-config-secret -n default

Phase 3C: cert-manager (with Vault PKI as the issuer)

This is the pattern that matters for customer conversations: cert-manager handling the Kubernetes-side automation (Certificate CRDs, renewal, Secret lifecycle) while Vault’s PKI secrets engine is the actual certificate authority signing things.

Step 3C.1 — Install cert-manager

bash
helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true

Verify:

bash
kubectl get pods -n cert-manager

Expected: three pods running (cert-manager, cert-manager-cainjector, cert-manager-webhook).

Step 3C.2 — Enable and configure Vault’s PKI secrets engine

Back to vault-0:

bash
kubectl exec -n vault -it vault-0 -- sh -c '
vault secrets enable pki
vault secrets tune -max-lease-ttl=8760h pki
vault write -field=certificate pki/root/generate/internal \
  common_name="lab.local" ttl=8760h > /dev/null
vault write pki/config/urls \
  issuing_certificates="http://vault.vault.svc.cluster.local:8200/v1/pki/ca" \
  crl_distribution_points="http://vault.vault.svc.cluster.local:8200/v1/pki/crl"
vault write pki/roles/lab-dot-local \
  allowed_domains="lab.local" \
  allow_subdomains=true \
  max_ttl="72h"
'

This generates a self-contained root CA inside Vault (fine for a lab; a real deployment would use an intermediate CA signed by something external) and defines a role that constrains what domains it’ll issue certs for.

Step 3C.3 — Give cert-manager a way to authenticate to Vault

For lab simplicity, use a scoped Vault token rather than wiring up a second Kubernetes-auth role (production deployments typically use serviceAccountRef-based Kubernetes auth here instead — worth doing later, but it adds RBAC/TokenRequest plumbing that isn’t the point of this exercise).

bash
kubectl exec -n vault -it vault-0 -- sh -c '
vault policy write cert-manager-pki - <<EOF
path "pki/sign/lab-dot-local" {
  capabilities = ["create", "update"]
}
EOF
vault token create -policy=cert-manager-pki -period=768h -field=token
'

Copy the token that’s printed, then store it as a Kubernetes Secret:

bash
kubectl create secret generic cert-manager-vault-token \
  -n default \
  --from-literal=token=<paste-token-here>

Step 3C.4 — Create the cert-manager Issuer

bash
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: vault-issuer
  namespace: default
spec:
  vault:
    path: pki/sign/lab-dot-local
    server: http://vault.vault.svc.cluster.local:8200
    auth:
      tokenSecretRef:
        name: cert-manager-vault-token
        key: token
EOF

Verify it’s healthy:

bash
kubectl describe issuer vault-issuer -n default

Look for Ready: True in the conditions.

Step 3C.5 — Request a certificate

bash
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: myapp-cert
  namespace: default
spec:
  secretName: myapp-tls
  duration: 24h
  renewBefore: 12h
  commonName: myapp.lab.local
  dnsNames:
  - myapp.lab.local
  issuerRef:
    name: vault-issuer
    kind: Issuer
EOF

Step 3C.6 — Verify issuance

bash
kubectl get certificate myapp-cert -n default

Expected: READY becomes True within a few seconds.

bash
kubectl get secret myapp-tls -n default -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -text -noout | grep -A1 Issuer

Expected: the issuer CN traces back to lab.local — the root CA you generated in Step 3C.2.

Step 3C.7 — Consume the cert in a test workload

bash
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: cert-consumer
  namespace: default
spec:
  containers:
  - name: app
    image: curlimages/curl
    command: ["sleep", "3600"]
    volumeMounts:
    - name: tls
      mountPath: /etc/certs
      readOnly: true
  volumes:
  - name: tls
    secret:
      secretName: myapp-tls
EOF
bash
kubectl exec -n default cert-consumer -- ls /etc/certs
kubectl exec -n default cert-consumer -- sh -c "cat /etc/certs/tls.crt | head -1"

Expected: ca.crt, tls.crt, tls.key present, and the first line of tls.crt is -----BEGIN CERTIFICATE-----.

Prove renewal works: cert-manager watches renewBefore and rotates automatically as expiry approaches. You don’t need to wait 12 hours to see the mechanism — the point to understand is that myapp-tls is a live-managed Secret, not a one-time artifact; anything mounting it will pick up rotated certs on the next volume refresh without you touching the Certificate resource again.

Clean up when done:

bash
kubectl delete pod cert-consumer -n default
kubectl delete certificate myapp-cert -n default
kubectl delete secret myapp-tls -n default

Phase 3 wrap-up: the three pathways compared

Agent Injector Vault Secrets Operator cert-manager (Vault PKI)
Mechanism Mutating webhook + sidecar Cluster-wide controller + CRDs Cluster-wide controller + CRDs
Delivery File written to shared volume Native Kubernetes Secret Native Kubernetes Secret (tls.crt/tls.key)
Auth to Vault Kubernetes auth, per-pod Kubernetes auth, per-VaultAuth Token or Kubernetes auth, per-Issuer
Update model Re-rendered by sidecar (pod-scoped) Polled sync on refreshAfter Watched, renewed before expiry
Best fit Apps that read a mounted file at startup, no code changes Apps that already expect a K8s Secret (env vars, existing manifests) Any workload needing TLS certs, with automated rotation

This is the exact framing customers ask for — “which one do I use” isn’t a maturity question, it’s a delivery-model question. You now have working, hand-built proof of all three, not just the marketing description of them.


Any time a Vault pod restarts (a helm upgrade, a manual kubectl delete pod, a node reboot), you’ll need some subset of these. Bookmark this section — you’ll come back to it more than any single numbered step.

Check cluster/pod state:

bash
kubectl get pods -n vault
kubectl exec -n vault -it vault-0 -- vault status

Unseal a pod (needed any time a pod restarts — seal state is in-memory only):

bash
kubectl exec -n vault -it <pod-name> -- vault operator unseal <unseal-key>

Re-authenticate your CLI session (needed any time a pod restarts — the token lives on an emptyDir volume that doesn’t survive):

bash
kubectl exec -n vault -it vault-0 -- vault login <root-token>

Check Raft peer health:

bash
kubectl exec -n vault -it vault-0 -- vault operator raft list-peers

Reach the UI:

bash
kubectl port-forward -n vault svc/vault 8200:8200
# then open http://127.0.0.1:8200/ui in a browser

Any helm upgrade on this release — always include these two flags regardless of what you’re changing, to avoid the two recurring gotchas covered in Phase 2’s troubleshooting table:

bash
helm upgrade vault hashicorp/vault \
  --namespace vault \
  --reuse-values \
  --set 'server.affinity=' \
  --server-side=false \
  --set 'whatever.you.are.actually.changing=value'

If a pod restart breaks scheduling again (stuck Pending, “does not have a host assigned”): see Phase 2’s “Fixing stuck anti-affinity Pending pods, in full” — the short version is you likely need to recreate all three pods, not just the stuck ones.