Kubernetes Secrets are only base64-encoded, anyone with get secrets in a namespace can read them, and they usually end up copied into manifests or CI variables. HashiCorp Vault keeps secrets in one encrypted store, controls access with policies, logs every read, and can hand secrets to pods at runtime. In this tutorial you will deploy Vault on Kubernetes with its official Helm chart, initialize and unseal it, store a secret, let pods authenticate with their ServiceAccount, and inject the secret into an application with the Vault Agent Injector.
Prerequisites
To follow this tutorial you need:
- A Kubernetes cluster (1.30 or newer) with a default StorageClass for persistent volumes. Run
kubectl get storageclassand check that one is marked(default). kubectlwith cluster-admin access and Helm 3 or 4 installed, for example on an Ubuntu 24.04 workstation or a CubePath VPS.- About 512 MB of free memory in the cluster for Vault and the injector.
This tutorial deploys a single Vault server, which is the right way to learn the workflow. The last section lists what to change for production.
Step 1 - Installing Vault with Helm
Add the HashiCorp Helm repository:
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update
"hashicorp" has been added to your repositories
...Successfully got an update from the "hashicorp" chart repository
Update Complete. ⎈Happy Helming!⎈
Install the chart into a vault namespace. By default it deploys one Vault server with a persistent volume, plus the Agent Injector, a mutating webhook that adds a Vault Agent sidecar to annotated pods:
helm install vault hashicorp/vault \
--namespace vault --create-namespace
Check the pods:
kubectl get pods -n vault
NAME READY STATUS RESTARTS AGE
vault-0 0/1 Running 0 45s
vault-agent-injector-7d8c6f9b5d-4xk2m 1/1 Running 0 45s
vault-0 shows 0/1 on purpose: its readiness probe only passes once Vault is initialized and unsealed. Confirm that with vault status:
kubectl exec -n vault vault-0 -- vault status
Key Value
--- -----
Seal Type shamir
Initialized false
Sealed true
...
The command exits with a non-zero code while Vault is sealed, so kubectl also prints command terminated with exit code 2. That is expected.
Step 2 - Initializing and unsealing Vault
Initialization creates the encryption keys and a root token. Vault splits the key that unlocks its storage into several unseal key shares; a threshold of them is required every time Vault starts. Initialize with 5 shares and a threshold of 3:
kubectl exec -n vault vault-0 -- vault operator init -key-shares=5 -key-threshold=3
Unseal Key 1: 1a2b3c...
Unseal Key 2: 4d5e6f...
Unseal Key 3: 7g8h9i...
Unseal Key 4: jklmno...
Unseal Key 5: pqrstu...
Initial Root Token: hvs.XXXXXXXXXXXXXXXXXXXXXXXX
...
ImportantThis is the only time Vault shows these values. Store the unseal keys and root token in a password manager right away, ideally with each key held by a different person. Anyone with three keys and access to the storage can decrypt all your secrets. Do not save them in plain files on the server.
Unseal Vault by running the following command three times, pasting a different unseal key each time. The key is read from a hidden prompt so it does not end up in your shell history:
kubectl exec -it -n vault vault-0 -- vault operator unseal
Unseal Key (will be hidden):
Key Value
--- -----
Sealed true
Unseal Progress 1/3
...
After the third key, Sealed changes to false and the pod becomes ready:
kubectl get pods -n vault vault-0
NAME READY STATUS RESTARTS AGE
vault-0 1/1 Running 0 4m
Vault seals itself again whenever the pod restarts, so you will repeat this step after node reboots or upgrades, unless you configure auto-unseal (see the production section).
Step 3 - Storing a secret in the KV engine
The rest of the Vault configuration is done with the vault CLI inside the pod, which already has VAULT_ADDR set. Open a shell there:
kubectl exec -it -n vault vault-0 -- /bin/sh
Log in with the root token. The prompt hides what you type:
vault login
Token (will be hidden):
Success! You are now authenticated.
...
Enable the key/value secrets engine, version 2 (which keeps a version history of each secret), at the path secret/:
vault secrets enable -path=secret kv-v2
Success! Enabled the kv-v2 secrets engine at: secret/
Store database credentials for an application called webapp. Replace the values with your own:
vault kv put secret/webapp/config username="webapp_user" password="your_strong_password"
Read it back to confirm:
vault kv get secret/webapp/config
====== Secret Path ======
secret/data/webapp/config
...
====== Data ======
Key Value
--- -----
password your_strong_password
username webapp_user
Note the path shown: KV version 2 stores data under secret/data/.... Policies and injector annotations must use that full path.
Step 4 - Enabling Kubernetes authentication
Pods should not carry Vault tokens. With the Kubernetes auth method, a pod presents its ServiceAccount token, Vault asks the Kubernetes API to validate it, and returns a Vault token with the policies mapped to that ServiceAccount. The Helm chart already gives Vault's ServiceAccount the system:auth-delegator role it needs to validate tokens.
Still in the vault-0 shell, enable and configure the method. When Vault runs inside the cluster, it only needs the API server address; it reads the CA certificate and its own token from the pod automatically:
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"
Success! Enabled kubernetes auth method at: kubernetes/
Success! Data written to: auth/kubernetes/config
Create a policy that allows reading only this one secret:
vault policy write webapp - <<EOF
path "secret/data/webapp/config" {
capabilities = ["read"]
}
EOF
Success! Uploaded policy: webapp
Map the policy to a ServiceAccount. This role says: a pod running as ServiceAccount webapp in namespace demo receives a token with the webapp policy, valid for 24 hours:
vault write auth/kubernetes/role/webapp \
bound_service_account_names=webapp \
bound_service_account_namespaces=demo \
policies=webapp \
ttl=24h
Success! Data written to: auth/kubernetes/role/webapp
Leave the pod shell:
exit
Step 5 - Injecting the secret into a pod
Create the namespace and ServiceAccount that the Vault role expects:
kubectl create namespace demo
kubectl create serviceaccount webapp -n demo
Now create a Deployment whose pod template carries the injector annotations:
nano webapp.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
namespace: demo
spec:
replicas: 1
selector:
matchLabels:
app: webapp
template:
metadata:
labels:
app: webapp
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "webapp"
vault.hashicorp.com/agent-inject-secret-db.env: "secret/data/webapp/config"
vault.hashicorp.com/agent-inject-template-db.env: |
{{- with secret "secret/data/webapp/config" -}}
DB_USER={{ .Data.data.username }}
DB_PASSWORD={{ .Data.data.password }}
{{- end }}
spec:
serviceAccountName: webapp
containers:
- name: app
image: nginx:1.27
What each annotation does:
agent-inject: "true"asks the injector to add Vault Agent to the pod: an init container that fetches secrets before the app starts, and a sidecar that keeps them up to date.roleis the Vault Kubernetes auth role created in Step 4.agent-inject-secret-db.envfetches the secret and writes it to/vault/secrets/db.envinside the pod. The part afteragent-inject-secret-becomes the file name.agent-inject-template-db.envcontrols the file format. Here it rendersKEY=valuelines that most applications and shells can load. With KV version 2 the values are under.Data.data.
Apply it and watch the pod start:
kubectl apply -f webapp.yaml
kubectl get pods -n demo
NAME READY STATUS RESTARTS AGE
webapp-5f7b9c8d6f-q8r2t 2/2 Running 0 25s
2/2 means the app container and the vault-agent sidecar are both running. Read the rendered file from the application container:
kubectl exec -n demo deploy/webapp -c app -- cat /vault/secrets/db.env
DB_USER=webapp_user
DB_PASSWORD=your_strong_password
The secret lives on an in-memory volume shared between the agent and your container. It never appears in the Deployment manifest, in etcd or in kubectl describe output.
Testing that access is scoped
Vault only issues a token to the exact ServiceAccount and namespace in the role. Check that a pod using the namespace's default ServiceAccount is refused:
kubectl patch deployment webapp -n demo --type=json \
-p='[{"op":"replace","path":"/spec/template/spec/serviceAccountName","value":"default"}]'
kubectl get pods -n demo
NAME READY STATUS RESTARTS AGE
webapp-5f7b9c8d6f-q8r2t 2/2 Running 0 3m
webapp-6c4d8e7a9b-z7m1k 0/2 Init:0/1 0 20s
The new pod stays in Init because Vault Agent cannot log in. Its log shows why; use the name of the pod stuck in Init from your output:
kubectl logs -n demo webapp-6c4d8e7a9b-z7m1k -c vault-agent-init --tail=5
... [ERROR] agent.auth.handler: error authenticating: ... service account name not authorized
Undo the change by reapplying the manifest:
kubectl apply -f webapp.yaml
Step 6 - Rotating the secret
Update the secret in Vault:
kubectl exec -it -n vault vault-0 -- /bin/sh -c \
'vault login -no-print && vault kv put secret/webapp/config username="webapp_user" password="new_strong_password"'
The Vault Agent sidecar periodically re-reads static KV secrets (every five minutes by default) and rewrites /vault/secrets/db.env when the value changes. Check the file again after a few minutes:
kubectl exec -n demo deploy/webapp -c app -- cat /vault/secrets/db.env
DB_USER=webapp_user
DB_PASSWORD=new_strong_password
Your application must re-read the file to pick up the change, either by watching it or on a restart (kubectl rollout restart deployment/webapp -n demo). If your application only reads secrets at startup, add the annotation vault.hashicorp.com/agent-pre-populate-only: "true" to drop the sidecar and keep only the init container.
Preparing Vault for production
The single-server install above is enough to learn and to run non-critical workloads. For production, plan these changes before you store real secrets:
- High availability: run three Vault servers with integrated Raft storage (
server.ha.enabled=trueandserver.ha.raft.enabled=truein the chart values) spread across nodes. - TLS: serve the Vault API over TLS inside the cluster and give the injected agents the CA certificate, for example with the
vault.hashicorp.com/tls-secretandvault.hashicorp.com/ca-certannotations. - Auto-unseal: configure a
sealstanza with a cloud KMS or a separate Vault Transit server, so pods recover after restarts without manual unsealing. - Audit log: enable an audit device, for example
vault audit enable file file_path=/vault/audit/audit.logwithserver.auditStorage.enabled=truein the chart values. - Root token: after initial setup, create admin identities (OIDC or userpass with a strict policy) and revoke the root token with
vault token revoke. Generate a new one only in emergencies withvault operator generate-root. - Backups: take periodic Raft snapshots with
vault operator raft snapshot save.
Troubleshooting
- Pods are created without the
vault-agentcontainers: the injector did not see the annotations. They must be on the pod template (spec.template.metadata.annotations), not on the Deployment's own metadata. Also check that thevault-agent-injectorpod is running. - Pod stuck in
Init:0/1: readkubectl logs <pod> -c vault-agent-init -n demo.permission deniedon the secret path usually means the policy path is missing/data/for KV version 2.service account name not authorizedmeans the role's ServiceAccount or namespace does not match the pod. vault-0stays0/1after a restart: Vault is sealed again. Unseal it with three keys as in Step 2.connection refusedfromvaultcommands: you are running the CLI outside the pod withoutVAULT_ADDR. Usekubectl execas in this tutorial, orkubectl port-forward -n vault svc/vault 8200:8200andexport VAULT_ADDR=http://127.0.0.1:8200.
Cleaning up
Remove the demo application and, if you do not want to keep Vault, uninstall it and delete its data volume:
kubectl delete namespace demo
helm uninstall vault -n vault
kubectl delete namespace vault
Deleting the vault namespace removes its PersistentVolumeClaim and, with it, every secret stored in this Vault instance.
Conclusion
You deployed Vault on Kubernetes with Helm, initialized and unsealed it, stored a secret in the KV engine, let pods authenticate with their ServiceAccount through the Kubernetes auth method, and delivered the secret to an application as a file without placing it in any manifest. Next, move toward the production layout above, explore dynamic secrets with the database secrets engine so each pod gets short-lived credentials, or use the Vault CSI provider if you prefer mounting secrets as CSI volumes instead of sidecars.
