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 storageclass and check that one is marked (default).
  • kubectl with 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
...

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.
  • role is the Vault Kubernetes auth role created in Step 4.
  • agent-inject-secret-db.env fetches the secret and writes it to /vault/secrets/db.env inside the pod. The part after agent-inject-secret- becomes the file name.
  • agent-inject-template-db.env controls the file format. Here it renders KEY=value lines 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=true and server.ha.raft.enabled=true in 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-secret and vault.hashicorp.com/ca-cert annotations.
  • Auto-unseal: configure a seal stanza 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.log with server.auditStorage.enabled=true in 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 with vault operator generate-root.
  • Backups: take periodic Raft snapshots with vault operator raft snapshot save.

Troubleshooting

  • Pods are created without the vault-agent containers: 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 the vault-agent-injector pod is running.
  • Pod stuck in Init:0/1: read kubectl logs <pod> -c vault-agent-init -n demo. permission denied on the secret path usually means the policy path is missing /data/ for KV version 2. service account name not authorized means the role's ServiceAccount or namespace does not match the pod.
  • vault-0 stays 0/1 after a restart: Vault is sealed again. Unseal it with three keys as in Step 2.
  • connection refused from vault commands: you are running the CLI outside the pod without VAULT_ADDR. Use kubectl exec as in this tutorial, or kubectl port-forward -n vault svc/vault 8200:8200 and export 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.