Vault Kubernetes Secrets with Dynamic Credentials
Deploy HashiCorp Vault on Kubernetes with Raft HA and KMS auto-unseal, then inject short-lived PostgreSQL credentials into pods with Vault Agent.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project deploys a three-node HashiCorp Vault cluster on Kubernetes with Raft storage and AWS KMS auto-unseal, then configures Vault to create a unique, short-lived PostgreSQL user for every pod that asks. The Vault Agent injector delivers those credentials to the application as a file, so the application image holds no database password and the Git repository holds none either. You finish by proving that credentials expire, can be revoked on demand, and are recorded in an audit log.
You need an EKS cluster with an OIDC provider (as in Deploy a Production EKS Cluster with Terraform), kubectl, Helm, eksctl, and the AWS CLI.
What you build:
- A KMS key and an IAM role so Vault can unseal itself after a restart
- A three-node Vault cluster with integrated Raft storage
- Kubernetes authentication, so a pod proves who it is with its service account
- A database secrets engine that issues expiring PostgreSQL users
- An application that receives credentials from a sidecar and never talks to Vault itself
auto-unseal]) -->|unseals| V subgraph VNS["Namespace: vault"] V[Vault
3 Raft nodes] INJ[Agent injector] end subgraph ANS["Namespace: default"] POD[App pod
app and Agent sidecar] end INJ -.->|adds sidecar| POD POD -->|login and read| V V -->|creates user| DB[(PostgreSQL)] POD -->|connects| DB classDef cloud fill:#F97316,color:#FFFFFF classDef vault fill:#8B5CF6,color:#FFFFFF classDef app fill:#326CE5,color:#FFFFFF classDef data fill:#10B981,color:#FFFFFF class KMS cloud class V,INJ vault class POD app class DB data
The problem this solves
A database password stored in a Git repository, a container image, or an environment variable can be found by anyone who can read those things. Environment variables are visible through kubectl describe pod and printenv. A leaked static password stays valid until someone notices and rotates it.
With dynamic secrets, no password exists until a workload asks for one. Vault creates a database user on demand, gives it a lease (a time limit), and deletes it when the lease ends. A stolen credential is useful only until the lease expires, and every issue is logged. You will measure these properties yourself in the validation step, so you can state exactly what your setup guarantees.
Tip"Zero hardcoded credentials" is a goal to verify, not a slogan. The validation step gives you concrete checks: nothing in the pod environment, nothing in Git, and credentials that stop working.
Milestone 1: Create the KMS Key and IAM Role for Auto-Unseal
This milestone creates the key that lets Vault unseal itself. Vault encrypts its data with a master key, and starts sealed, unable to read anything until it is unsealed. By default a human supplies key shares after every restart. With auto-unseal, Vault asks AWS KMS to decrypt its master key, so a restarted pod recovers on its own.
export CLUSTER=your-cluster-nameexport REGION=ap-south-1export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) export KMS_KEY_ID=$(aws kms create-key \ --description "Vault auto-unseal" \ --region $REGION \ --query KeyMetadata.KeyId --output text) export KMS_KEY_ARN=$(aws kms describe-key --key-id $KMS_KEY_ID --region $REGION \ --query KeyMetadata.Arn --output text) cat > vault-kms-policy.json <<EOF{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["kms:Encrypt", "kms:Decrypt", "kms:DescribeKey"], "Resource": "$KMS_KEY_ARN" }]}EOF aws iam create-policy \ --policy-name VaultKMSUnsealPolicy \ --policy-document file://vault-kms-policy.jsonCreate the namespace and a service account that carries an IAM role (IRSA). eksctl creates the role, its trust policy, and the annotated service account in one step:
kubectl create namespace vault eksctl create iamserviceaccount \ --cluster $CLUSTER \ --namespace vault \ --name vault \ --attach-policy-arn arn:aws:iam::$ACCOUNT_ID:policy/VaultKMSUnsealPolicy \ --approveCommon MistakeCreating the IAM policy but never creating the role that Vault assumes. The Helm values then point at a role that does not exist, and Vault fails to start with a KMS permission error. The service account above is what connects the two.
Milestone 2: Install a Three-Node Vault Cluster
This milestone installs Vault in high-availability mode using the official Helm chart. Raft is Vault's built-in storage, so you do not need a separate Consul or etcd. One node is the active leader and two are standbys that take over if it fails.
Look up the current chart version and pin it:
helm repo add hashicorp https://helm.releases.hashicorp.comhelm repo updatehelm search repo hashicorp/vaultexport VAULT_CHART_VERSION=<version from the search above>Create vault-values.yaml. The service account already exists, so the chart must not create its own.
global: enabled: true tlsDisable: true # lab only, see the security note below injector: enabled: true server: serviceAccount: create: false name: vault ha: enabled: true replicas: 3 raft: enabled: true setNodeId: true config: | ui = true listener "tcp" { tls_disable = 1 address = "[::]:8200" cluster_address = "[::]:8201" } 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 "awskms" { region = "ap-south-1" kms_key_id = "YOUR_KMS_KEY_ID" } service_registration "kubernetes" {} dataStorage: enabled: true size: 10Gi ui: enabled: true serviceType: ClusterIPReplace YOUR_KMS_KEY_ID with the value of $KMS_KEY_ID, then install:
helm install vault hashicorp/vault \ --namespace vault \ --values vault-values.yaml \ --version $VAULT_CHART_VERSION kubectl get pods -n vault --watch# Expected: vault-0, vault-1, vault-2 Running but 0/1 Ready, plus the injector 1/1The Vault pods stay 0/1 ready until the cluster is initialized, which is correct.
SecurityThis lab turns TLS off inside the cluster to keep the focus on secrets. That is not acceptable for production, where Vault traffic must use TLS and the listener, probes, and client addresses all change to
https. Treat TLS as the first item on your production list.
Milestone 3: Initialize Vault and Secure the Root Token
This milestone initializes the cluster, which generates the root token and recovery keys. With auto-unseal, KMS holds the unseal key, so what you receive are recovery keys, used only for emergency operations such as generating a new root token. Pass the recovery flags, not the key-share ones.
kubectl exec -n vault vault-0 -- vault operator init \ -recovery-shares=1 \ -recovery-threshold=1 \ -format=json > vault-init.jsonvault-init.json holds the root token and a recovery key. Move it to a password manager or secrets store, then delete it from your disk. Anyone with the root token can do anything in Vault.
The other two nodes join through retry_join and unseal themselves with KMS:
kubectl get pods -n vault# Expected: vault-0, vault-1 and vault-2 all 1/1 Ready kubectl exec -n vault vault-0 -- vault status# Expected: Initialized true, Sealed false, HA Enabled trueOpen a shell in the pod for the Vault commands that follow:
kubectl exec -it -n vault vault-0 -- shInside that shell, log in with the root token, confirm the cluster, and replace the root token with a short-lived admin token:
# inside the Vault podvault login # paste the root token when asked vault operator raft list-peers# Expected: three peers, one leader and two followers vault policy write admin - <<'EOF'path "*" { capabilities = ["create", "read", "update", "delete", "list", "sudo"]}EOF vault token create -policy=admin -ttl=8hLog in again with the new admin token, then revoke the root token (vault token revoke ROOT_TOKEN). You can generate a new one later with the recovery key if you ever need it.
SecurityAn
adminpolicy with*is root in everything but name. It is acceptable for a lab. In production, humans sign in through OIDC or SSO, and policies are narrow.
Milestone 4: Enable Kubernetes Authentication
This milestone lets a pod prove its identity to Vault. A pod presents its service account token, and Vault checks it against the Kubernetes API. If the token belongs to a service account that a Vault role trusts, Vault returns a token carrying only that role's policies.
Run these in the Vault pod shell:
# inside the Vault podvault auth enable kubernetes vault write auth/kubernetes/config \ kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"Write a policy that allows reading one credential path and nothing else:
# inside the Vault podvault policy write webapp-policy - <<'EOF'path "database/creds/webapp-role" { capabilities = ["read"]}EOF vault write auth/kubernetes/role/webapp \ bound_service_account_names=webapp \ bound_service_account_namespaces=default \ policies=webapp-policy \ ttl=1hThe role binds one service account name and one namespace. A pod with a different service account cannot obtain these credentials, even if it runs next to this one.
Common MistakeA mismatch between
serviceAccountNamein the Deployment andbound_service_account_namesin the Vault role. The sidecar cannot log in, and the pod hangs inInitor its sidecar logs a permission denied error. The two names must match exactly.
Milestone 5: Configure Dynamic PostgreSQL Credentials
This milestone connects Vault to a database and defines how it creates users. Vault logs in as an admin, runs your SQL to create a role with an expiry, and drops it when the lease ends.
First deploy a test database. Its admin password lives in a Secret, not in the manifest:
kubectl create secret generic postgres-root --from-literal=password='temporary-admin-password' kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata: name: postgresspec: replicas: 1 selector: matchLabels: app: postgres template: metadata: labels: app: postgres spec: containers: - name: postgres image: postgres:16-alpine env: - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-root key: password - name: POSTGRES_DB value: appdb ports: - containerPort: 5432---apiVersion: v1kind: Servicemetadata: name: postgresspec: selector: app: postgres ports: - port: 5432EOF kubectl rollout status deployment/postgreskubectl exec deploy/postgres -- psql -U postgres -d appdb \ -c "CREATE TABLE orders (id serial PRIMARY KEY, item text);"Back in the Vault pod shell, enable the engine and register the database. The {{username}} and {{password}} placeholders in the URL are filled in by Vault:
# inside the Vault podvault secrets enable database vault write database/config/postgresql \ plugin_name=postgresql-database-plugin \ allowed_roles="webapp-role" \ connection_url="postgresql://{{username}}:{{password}}@postgres.default.svc.cluster.local:5432/appdb?sslmode=disable" \ username="postgres" \ password="temporary-admin-password" # Rotate the admin password so only Vault knows it from now onvault write -f database/rotate-root/postgresqlDefine the role. The SQL goes in a file so the quoting stays readable:
# inside the Vault podcat > /tmp/creation.sql <<'EOF'CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO "{{name}}";EOF vault write database/roles/webapp-role \ db_name=postgresql \ creation_statements=@/tmp/creation.sql \ default_ttl=1h \ max_ttl=24h vault read database/creds/webapp-rolevault read database/creds/webapp-roleRun the last command twice. Each call returns a different username and password, both valid at the same time, each with its own lease.
Tip
rotate-rootreplaces the admin password you typed with one only Vault knows. Afterwards, even the person who ran this lab cannot log in to the database as that admin.
Milestone 6: Inject Credentials into an Application
This milestone delivers credentials to a pod without any Vault code in the application. The Vault Agent injector is a mutating webhook: when it sees the annotations below, it adds a sidecar that logs in to Vault, fetches the secret, renders it to a file, and renews the lease for as long as it is allowed.
kubectl create serviceaccount webapp kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata: name: webappspec: replicas: 2 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-creds: "database/creds/webapp-role" vault.hashicorp.com/agent-inject-template-db-creds: | {{- with secret "database/creds/webapp-role" -}} DB_USERNAME={{ .Data.username }} DB_PASSWORD={{ .Data.password }} DB_HOST=postgres.default.svc.cluster.local DB_PORT=5432 DB_NAME=appdb {{- end }} spec: serviceAccountName: webapp containers: - name: webapp image: nginx:alpine # replace with your application imageEOF kubectl get pods -l app=webapp# Expected: 2/2 Running (the app plus the Vault Agent sidecar) kubectl exec deploy/webapp -c webapp -- cat /vault/secrets/db-credsThe application reads /vault/secrets/db-creds. Credentials rotate as leases renew, so a real application must re-read the file or be signalled when it changes. The injector supports a command annotation for that, and many teams run a short reload hook.
Now prove the credentials work against the database. The connection goes through the service name, which forces password authentication:
kubectl exec deploy/webapp -c webapp -- cat /vault/secrets/db-creds > /tmp/creds.envsource /tmp/creds.env kubectl exec deploy/postgres -- env PGPASSWORD="$DB_PASSWORD" \ psql -h postgres.default.svc.cluster.local -U "$DB_USERNAME" -d appdb -c "SELECT 1;"# Expected: a row with the value 1Common MistakeTesting from inside the database pod over
127.0.0.1or a Unix socket. The default image trusts local connections, so the password is never checked and the test proves nothing. Use the service address, as above.
Milestone 7: Turn On Audit Logging
This milestone records every request to Vault. Audit logging answers "which workload read which secret, and when", which is a core reason to use Vault at all. Send the log to standard output so your normal log collection can pick it up:
# inside the Vault podvault audit enable file file_path=stdoutkubectl exec -n vault vault-0 -- vault read database/creds/webapp-rolekubectl logs -n vault vault-0 | grep "database/creds/webapp-role" | head -3Each entry names the path, the time, the client, and the identity that made the request. Secret values are not written in clear text: Vault stores salted hashes, so the log can be shared without leaking credentials.
Validation & Testing
Check each property, and record what you saw.
# 1. Cluster healthkubectl exec -n vault vault-0 -- vault statuskubectl exec -n vault vault-0 -- vault operator raft list-peers# Expected: unsealed, HA enabled, three peers # 2. Credentials are unique per requestkubectl exec -n vault vault-0 -- vault read -format=json database/creds/webapp-role | grep usernamekubectl exec -n vault vault-0 -- vault read -format=json database/creds/webapp-role | grep username# Expected: two different usernames # 3. No password in the pod environmentkubectl exec deploy/webapp -c webapp -- env | grep -i password# Expected: no output # 4. No credential in Git history of your manifestsgit log -p | grep -i "temporary-admin-password"# Expected: no output (the admin password was rotated and lives only in Vault)Expiry. Shorten the lifetime, issue a credential, and confirm it stops working:
# inside the Vault podvault write database/roles/webapp-role db_name=postgresql \ creation_statements=@/tmp/creation.sql default_ttl=2m max_ttl=5mvault read database/creds/webapp-roleTest the new login with the psql command from Milestone 6, wait about five minutes, and repeat it. Expected: the second attempt fails.
Revocation. Revoke every credential at once, as you would after a suspected leak:
# inside the Vault podvault lease revoke -prefix database/creds/webapp-rolekubectl exec deploy/postgres -- psql -U postgres -d appdb -c "\du"# Expected: the generated v-... roles are goneAuto-unseal. Delete a pod and time the recovery:
kubectl delete pod vault-0 -n vaultkubectl get pod vault-0 -n vault --watchkubectl exec -n vault vault-0 -- vault status# Expected: Sealed false, with no human actionWrite your results in a table in the README:
| Property | Target | Measured |
|---|---|---|
| Credential lifetime in the app | Matches default_ttl |
|
| Time for a restarted node to unseal | Under a few minutes | |
| Credentials usable after expiry | None | |
| Credentials usable after revoke | None | |
| Passwords found in env, image, or Git | None |
In your portfolio, include the architecture diagram, the audit log excerpt, and the completed table. Add a short list of what production needs beyond this lab: TLS on every Vault listener, narrow human policies behind SSO, network policies around the Vault namespace, scheduled Raft snapshots (vault operator raft snapshot save), and an alert if the audit device fails.
Clean up with helm uninstall vault -n vault, delete the namespace, and schedule the KMS key for deletion. Remember that the Raft volumes hold encrypted data until you delete the persistent volume claims.