Running HashiCorp Vault on Kubernetes: HA, Auto-Unseal, and Rotating Secrets
A Kubernetes Secret is not encrypted. It is base64, which is just encoding with extra steps. Anyone who can read etcd, or an old backup of it, or a snapshot that leaked to the wrong bucket, can read every database password, API key, and token you have ever stored. base64 decodes in one command.
Vault fixes this by moving secrets out of the cluster and handing them out under a policy, with a short lifetime and an audit trail. This guide builds a production-shaped Vault on Kubernetes: a highly available Raft cluster, auto-unsealed by a second Vault, delivering secrets into a running app, then rotating those secrets while the app keeps serving traffic. I ran every command below against a live kind cluster, so the output is real.
This is Part 1. Part 2 covers disaster recovery and migration.
What Vault actually gives you
Before the helm install, it helps to know what you’re signing up for. Vault is not just an encrypted key/value store; that’s the smallest part of it. The pieces you’ll reach for:
- KV secrets. Versioned static secrets (
secret/data/...) for the familiar case of putting a password somewhere safe. - Dynamic secrets. Vault creates a credential on request, unique per consumer, that expires on its own. No human ever sees a long-lived database password.
- Encryption as a service. The
transitengine encrypts and decrypts data for your app so the keys never leave Vault. Your database stores ciphertext. - Identity and policies. Every request authenticates (here, with a Kubernetes service account) and gets checked against a least-privilege policy.
- Leasing, renewal, and revocation. Every secret has a lease. Vault can revoke one, or all of them, at once.
- Audit. Every request and response goes to an audit device.
The rest of this guide wires up four of those: KV, dynamic database credentials, transit, and Kubernetes identity.
The topology we’re building
Two Vault deployments, one Postgres, one app:
vault-transitis a small single Vault that runs only thetransitengine. Its job is to auto-unseal the main cluster.vaultis the real cluster: three Vault pods in HA mode using Integrated Storage (Raft), so there’s no external Consul to run. Each pod lands on its own node.postgresplus a tinyvaultdemoapp that reads its database credentials from Vault.
Here’s how the pieces connect. The app never talks to the database with a stored password; it authenticates to Vault and gets a fresh credential:
Local vs production. I built this on a 4-node
kindcluster (one control plane, three workers). To keep it laptop-sized I disabled TLS and shrank the resource requests. Both are called out inline. Keep TLS on and size the pods properly in production.
Install the chart
Add the repo and pin the chart version. Pinning matters: an unpinned helm install is not reproducible.
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update
helm search repo hashicorp/vault --versions | head
# chart 0.34.0 deploys Vault 2.0.3
First, the auto-unseal provider
A freshly started Vault is sealed: it holds ciphertext but not the key to decrypt it. Someone has to supply the unseal key on every start, and doing that by hand on every pod restart does not scale. So production uses auto-unseal, where an external service holds the key. Cloud KMS is the usual choice, but on a laptop there’s no KMS. The portable answer is transit auto-unseal: a second Vault hands out the unseal operation through its transit engine.
Install the transit Vault as a standalone server:
kubectl create namespace vault
helm install vault-transit hashicorp/vault \
--namespace vault --version 0.34.0 \
--values vault-transit-values.yaml
Initialize and unseal it once (this one uses Shamir keys, since someone has to unseal the unsealer), then create the key and a token the main cluster will use:
kubectl -n vault exec vault-transit-0 -- vault operator init \
-key-shares=1 -key-threshold=1 # 1/1 is fine for the unsealer in a lab
kubectl -n vault exec vault-transit-0 -- vault operator unseal <key>
# enable transit, create the auto-unseal key, scope a policy to just that key
vault secrets enable transit
vault write -f transit/keys/autounseal
vault policy write autounseal - <<'EOF'
path "transit/encrypt/autounseal" { capabilities = ["update"] }
path "transit/decrypt/autounseal" { capabilities = ["update"] }
EOF
AUTOTOKEN=$(vault token create -orphan -policy=autounseal -period=24h -field=token)
kubectl -n vault create secret generic vault-transit-token --from-literal=token="$AUTOTOKEN"
That token is the only thing the main cluster needs to auto-unseal. It goes into a Kubernetes Secret, not into a config file.
Then, the HA Raft cluster
The main cluster’s values enable HA plus Raft, keep the chart’s default one-pod-per-node anti-affinity, and add a seal "transit" stanza pointing at the transit Vault. The token arrives as the VAULT_TOKEN environment variable, sourced from the Secret:
server:
extraSecretEnvironmentVars:
- envName: VAULT_TOKEN
secretName: vault-transit-token
secretKey: token
ha:
enabled: true
replicas: 3
raft:
enabled: true
setNodeId: true
config: |
listener "tcp" {
tls_disable = 1 # LOCAL ONLY: use TLS in production
address = "[::]:8200"
}
storage "raft" {
path = "/vault/data"
retry_join { leader_api_addr = "http://vault-0.vault-internal:8200" }
retry_join { leader_api_addr = "http://vault-1.vault-internal:8200" }
retry_join { leader_api_addr = "http://vault-2.vault-internal:8200" }
}
seal "transit" {
address = "http://vault-transit.vault.svc:8200"
key_name = "autounseal"
mount_path = "transit/"
}
service_registration "kubernetes" {}
helm install vault hashicorp/vault \
--namespace vault --version 0.34.0 \
--values vault-ha-values.yaml
Initialize and watch it auto-unseal
With auto-unseal configured, init produces recovery keys instead of unseal keys. You no longer feed keys in on startup:
kubectl -n vault exec vault-0 -- vault operator init \
-recovery-shares=1 -recovery-threshold=1
The moment vault-0 is initialized, it calls the transit Vault, decrypts its root key, and unseals itself. The other two pods find the leader through retry_join and unseal the same way, with no human in the loop:
$ vault status
Seal Type transit
Recovery Seal Type shamir
Initialized true
Sealed false
HA Mode active
$ vault operator raft list-peers
Node Address State Voter
vault-0 vault-0.vault-internal:8201 leader true
vault-1 vault-1.vault-internal:8201 follower true
vault-2 vault-2.vault-internal:8201 follower true
$ vault operator raft autopilot state | grep -E 'Healthy|Failure'
Healthy: true
Failure Tolerance: 1
Failure Tolerance: 1 is the payoff of three nodes: one can die and the cluster keeps serving. The Seal Type transit line confirms the auto-unseal path is live.
Give an app a secret
A secret nobody can reach is not useful. The delivery path has three parts: an auth method that proves who the pod is, a policy that says what it may read, and the Agent Injector that renders the secret into the pod.
The full login-to-connect exchange, using the pod’s own Kubernetes identity:
Enable Kubernetes auth and a KV secret:
vault secrets enable -path=secret kv-v2
vault kv put secret/app/config username=app_user password='S3cret-P@ss'
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"
Write a least-privilege policy that reads exactly the paths the app needs and nothing else, plus a role binding a specific service account to it:
vault policy write app - <<'EOF'
path "secret/data/app/config" { capabilities = ["read"] }
path "database/creds/app-dynamic" { capabilities = ["read"] }
path "database/static-creds/app-static" { capabilities = ["read"] }
EOF
vault write auth/kubernetes/role/app \
bound_service_account_names=app-sa \
bound_service_account_namespaces=apps \
policies=app ttl=1h
Gotcha I hit. I first set
audience=vaulton the role. The injected service-account token carriesaud: ["https://kubernetes.default.svc.cluster.local"], so every login failed withinvalid audience (aud) claim. Either dropaudience(the service-account-name binding is the real control) or project a token whose audience matches. Note thatvault writeon an existing role does not clear a field you omit. I had tovault deletethe role and recreate it.
The app opts in with pod annotations. The injector sees them, authenticates with the pod’s service account, and writes each secret to /vault/secrets/:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "app"
vault.hashicorp.com/agent-inject-secret-db-creds: "database/creds/app-dynamic"
vault.hashicorp.com/agent-inject-template-db-creds: |
{{- with secret "database/creds/app-dynamic" -}}
username={{ .Data.username }}
password={{ .Data.password }}
{{- end }}
My vaultdemo app reads that file and connects to Postgres. Its /healthz proves the whole chain end to end:
{
"db_connected": true,
"db_current_user": "v-kubernet-app-dyna-4oOhMvRhPqRe0Lsk923x-1784913608",
"kv_loaded": true,
"time": "2026-07-24T17:21:11Z"
}
The app never held a database password. It connected as a Vault-generated user that did not exist a minute earlier.
Rotate the secrets
This is where Vault earns its cost. Three rotation patterns, all validated against the running cluster.
Dynamic credentials: rotation by design
The database engine mints a new user per request, each with a short TTL. Two reads, two different users:
$ vault read -field=username database/creds/app-dynamic
v-root-app-dyna-k56ik07akzDedZXw0SUd-1784913724
$ vault read -field=username database/creds/app-dynamic
v-root-app-dyna-W5NOFVIBIU09Kv8lERGg-1784913724
Nothing to rotate manually. The credential expires and Vault drops the Postgres role. The app’s injected file updates when the lease renews, so a rotated credential shows up without a restart.
Static roles: scheduled rotation of an existing user
Sometimes you can’t hand out throwaway users; you have one fixed account. A static role lets Vault own that account’s password and rotate it on a schedule (rotation_period), or on demand:
# before
password V-o4NEvUay5GhXLNsDWd
$ vault write -f database/rotate-role/app-static
# after: same user, new password, no app change
password 8-6N4mb1Oa81utUEXo67
Transit: rotate the encryption key, keep old ciphertext readable
Rotating an encryption key normally means re-encrypting everything. Transit versions the key, so a rotation is instant and old data still decrypts:
$ vault write -field=ciphertext transit/encrypt/app-data plaintext=$(echo -n 'hello startower' | base64)
vault:v1:uIyeePMFTiagf3UYGmIO0Arex...
$ vault write -f transit/keys/app-data/rotate # now on version 2
$ vault write -field=plaintext transit/decrypt/app-data ciphertext="vault:v1:uIyee..." | base64 -d
hello startower
New writes use v2, and the vault:v1: ciphertext still decrypts. You rewrap on your own schedule instead of in a panicked migration.
Operational hardening checklist
The walkthrough disables a few things for the laptop. Before this goes anywhere real, work down this list. Every item is straight from Vault’s own production-hardening guidance:
- Turn TLS back on. End to end, including the
retry_joinbetween Raft peers.tls_disable = 1is for demos only. - Revoke the initial root token once setup is done. It’s a break-glass credential, not a daily driver.
- Enable an audit device (
vault audit enable file ...) so every request is logged. - Take Raft snapshots on a schedule (
vault operator raft snapshot save) and before every upgrade, then store them off-cluster and test-restore them. - Upgrade in the right order. The chart’s
updateStrategyType: OnDeletemeans pods don’t roll automatically; restart standbys first and the active leader last, so you never fail over to an older version. - Keep policies least-privilege and TTLs short. Grant paths, not wildcards.
- Size the pods. The chart ships empty resource requests; set them.
What you have now
A Vault cluster that survives a node failure, unseals itself without a human, and hands your app credentials that rotate on their own. The database never holds a long-lived password, and neither does your app.
The one thing this setup can’t yet survive is losing the cluster itself: a bad snapshot restore, a region gone, a migration to new infrastructure. That’s Part 2: disaster recovery and migration for Vault, where the Raft snapshots above stop being a checkbox and become the plan. Then in Part 3 I break this cluster on purpose to find out which failures heal themselves and which need a human.