How We Built a Centralized Secrets Management System on AWS EKS Using HashiCorp Vault

One Vault cluster. Multiple teams. Zero secrets in Git. Here is the complete setup, flow, and onboarding guide.
Why We Built This
Every growing engineering team hits the same wall. Secrets start in .env files, move to config maps, then someone accidentally pushes a token to Git and now you have a real problem.
We needed one place where all secrets live — one source of truth — with proper access control, automatic rotation, audit logs, and zero manual work when a new app joins.
This is what we built.
The Big Picture
Before going into steps, understand the shape of the system:
AWS KMS
└── Auto-unseals Vault on every restart (no human needed)
Central Vault Cluster (3 nodes on EKS)
├── vault-0 → Leader
├── vault-1 → Follower
└── vault-2 → Follower
└── Raft storage (built-in HA, no external database)
External Secrets Operator (ESO)
└── Watches Vault, creates K8s secrets automatically
Application Clusters (cluster-1, cluster-2 ... cluster-N)
└── Each has its own ESO + K8s secrets
└── All connect to the same central Vault
One central Vault serves all clusters. Each cluster has its own ESO instance that authenticates to Vault and pulls secrets into its namespaces. Application pods never talk to Vault directly — they just read environment variables from Kubernetes secrets.
Secret Path Structure
We agreed on this folder structure before storing a single secret. A clear structure makes policies, access control, and onboarding much easier.
secret/
└── mycompany/
├── stg/
│ └── <namespace>/
│ ├── normal/ → non-sensitive: URLs, feature flags, config
│ └── critical/ → sensitive: passwords, API keys, DB creds, tokens
└── prod/
└── <namespace>/
├── normal/
└── critical/
Examples:
mycompany/stg/backend/normal/payments-servicemycompany/prod/backend/critical/payments-servicemycompany/stg/aiml/normal/model-server
The normal vs critical split matters for RBAC. Developers can see and edit normal in non-prod. They cannot see values in critical for production — only DevOps can.
RBAC: Who Can See What
| Role | Non-prod normal | Non-prod critical | Prod normal | Prod critical |
|---|---|---|---|---|
devops |
Full | Full | Full | Full |
dev |
Full | Full | Write only | Keys only — no values |
qa |
Full | Full | Read only | Keys only — no values |
readonly |
Read | Read | Read | Keys only — no values |
"Keys only — no values" means a developer can see that a secret called DB_PASSWORD exists but cannot read its value. This protects production credentials without blocking day-to-day work.
Part 1 — Setting Up the Central Vault Cluster
Do this once. After this, the central cluster runs itself.
Step 1 — Namespace and Helm Repo
helm repo add hashicorp https://helm.releases.hashicorp.com
kubectl create namespace vault
Step 2 — TLS Certificate
Vault requires TLS. We use a self-signed cert valid for 10 years covering all internal DNS names.
openssl req -newkey rsa:2048 -nodes \
-keyout vault.key \
-x509 -days 3650 \
-out vault.crt \
-subj "/CN=vault" \
-addext "subjectAltName=\
DNS:vault,\
DNS:vault.vault.svc,\
DNS:vault.vault.svc.cluster.local,\
DNS:vault-0.vault-internal,\
DNS:vault-1.vault-internal,\
DNS:vault-2.vault-internal,\
IP:127.0.0.1"
kubectl create secret generic vault-tls \
--namespace vault \
--from-file=tls.crt=vault.crt \
--from-file=tls.key=vault.key \
--from-file=ca.crt=vault.crt
Step 3 — AWS KMS Key
This key is what allows Vault to unseal itself automatically after a restart. No human needs to enter unseal keys ever again.
aws kms create-key \
--description "Vault Auto Unseal" \
--query 'KeyMetadata.KeyId' \
--output text
# Save the key ID — you will need it in helm-values.yaml
Step 4 — IAM Policy and Role
Create this in the AWS Console.
IAM Policy — KMS permissions only:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["kms:Encrypt", "kms:Decrypt", "kms:DescribeKey", "kms:GenerateDataKey"],
"Resource": "arn:aws:kms:us-east-1:123456789012:key/YOUR-KEY-ID"
}
]
}
IAM Role — Web Identity trust with OIDC:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/YOUR-OIDC-ID"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/YOUR-OIDC-ID:sub": "system:serviceaccount:vault:vault",
"oidc.eks.us-east-1.amazonaws.com/id/YOUR-OIDC-ID:aud": "sts.amazonaws.com"
}
}
}]
}
Step 5 — Kubernetes Service Account (IRSA)
IRSA lets Vault authenticate to AWS without storing credentials anywhere. The pod gets a short-lived OIDC token that it exchanges for temporary AWS credentials.
kubectl create serviceaccount vault --namespace vault
kubectl annotate serviceaccount vault \
--namespace vault \
eks.amazonaws.com/role-arn=arn:aws:iam::123456789012:role/VaultEKSRole
Test it before moving on:
kubectl run test-aws --rm -it --restart=Never \
--image=amazon/aws-cli \
--overrides='{"spec":{"serviceAccountName":"vault"}}' \
-n vault \
-- sts get-caller-identity
# Must return account 123456789012
Step 6 — Deploy Vault via Helm
Key change settings in default file of the helm-values.yaml:
ha:
enabled: true
replicas: 3
raft:
enabled: true
setNodeId: true
config: |
ui = true
listener "tcp" {
tls_disable = 0
address = "[::]:8200"
cluster_address = "[::]:8201"
tls_cert_file = "/vault/userconfig/vault-tls/tls.crt"
tls_key_file = "/vault/userconfig/vault-tls/tls.key"
tls_client_ca_file = "/vault/userconfig/vault-tls/ca.crt"
}
storage "raft" {
path = "/vault/data"
retry_join {
leader_api_addr = "https://vault-0.vault-internal:8200"
leader_ca_cert_file = "/vault/userconfig/vault-tls/ca.crt"
}
retry_join {
leader_api_addr = "https://vault-1.vault-internal:8200"
leader_ca_cert_file = "/vault/userconfig/vault-tls/ca.crt"
}
retry_join {
leader_api_addr = "https://vault-2.vault-internal:8200"
leader_ca_cert_file = "/vault/userconfig/vault-tls/ca.crt"
}
}
seal "awskms" {
region = "us-east-1"
kms_key_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
service_registration "kubernetes" {}
helm upgrade --install vault hashicorp/vault \
--namespace vault \
--values helm-values.yaml \
--set ui.serviceType=ClusterIP
kubectl get pods -n vault -w
# Wait for all 3 pods to show 1/1 Running
Step 7 — Initialize Vault (Run Once Only)
⚠️ This is the most important step. You only do this once. Save everything immediately.
kubectl exec -n vault vault-0 -- vault operator init \
-recovery-shares=5 \
-recovery-threshold=3 \
-format=json > vault-init.json
# Save to AWS Secrets Manager immediately
aws secretsmanager create-secret \
--name vault/recovery-keys \
--secret-string file://vault-init.json \
--region us-east-1
Post-init setup (also run once):
ROOT_TOKEN=$(cat vault-init.json | jq -r '.root_token')
kubectl exec -n vault vault-0 -- vault login $ROOT_TOKEN
# Enable Kubernetes auth method
kubectl exec -n vault vault-0 -- vault auth enable kubernetes
kubectl exec -n vault vault-0 -- vault write auth/kubernetes/config \
kubernetes_host="https://kubernetes.default.svc:443"
# Enable KV v2 secrets engine
kubectl exec -n vault vault-0 -- vault secrets enable -path=secret kv-v2
# Enable username/password login for the UI
kubectl exec -n vault vault-0 -- vault auth enable userpass
# Enable audit logging
kubectl exec -n vault vault-0 -- vault audit enable file \
file_path=/vault/audit/vault-audit.log
# Verify all 3 nodes are in the Raft cluster
kubectl exec -n vault vault-0 -- vault operator raft list-peers
Step 8 — Expose via Ingress
Deploy an internal ALB so the Vault UI is accessible from inside the VPC and VPN.
kubectl apply -f helm-ingress.yaml
kubectl get ingress -n vault
# Create Route53 CNAME: vault.yourdomain.com → ALB DNS
Step 9 — Install External Secrets Operator
One ESO instance in the vault namespace. It serves ALL namespaces in the cluster.
helm repo add external-secrets https://charts.external-secrets.io
helm upgrade --install external-secrets external-secrets/external-secrets \
-n vault
kubectl apply -f external-pod-secret-operator.yaml
kubectl get clustersecretstore
# STATUS must show: Valid
Store the root token for ESO to use:
kubectl create secret generic vault-root-token \
--namespace vault \
--from-literal=token=$ROOT_TOKEN
Step 10 — Namespace Access Control
Control which namespaces ESO can pull secrets into. Edit the ALLOWED_NAMESPACES list:
- name: ALLOWED_NAMESPACES
value: "backend,frontend,aiml,staging"
kubectl apply -f vault-namespace-policies.yaml
kubectl exec -n vault vault-0 -- vault policy list
Step 11 — RBAC and User Management
Apply all RBAC policies:
kubectl apply -f vault-rbac.yaml
kubectl logs -n vault -l job-name=vault-rbac-setup
Add users by editing the USERS section:
- name: USERS
value: |
alice:Pass@123:devops:
bob:Pass@123:dev:backend
carol:Pass@123:qa:backend+frontend
dave:Pass@123:readonly:
kubectl apply -f vault-user.yaml
Central cluster is fully live. You never need to touch most of these files again.
Part 2 — Onboarding a New Cluster
Repeat these steps for every new cluster you want to connect to central Vault.
On the New Cluster
Step 1 — Switch context and install ESO:
aws eks update-kubeconfig --region us-east-1 --name your-new-cluster
helm upgrade --install external-secrets external-secrets/external-secrets \
-n external-secrets --create-namespace
kubectl get pods -n external-secrets -w
# Wait for 1/1 Running
Step 2 — Apply setup file:
Edit newcluster-eso-setup.yaml — change only this line:
mountPath: "kubernetes-your-new-cluster-name"
kubectl apply -f newcluster-eso-setup.yaml
Step 3 — Export service account token and CA cert:
kubectl get secret external-secrets-token -n external-secrets \
-o jsonpath='{.data.token}' | base64 -d > /tmp/sa-token.txt
kubectl config view --raw --minify --flatten \
-o jsonpath='{.clusters[].cluster.certificate-authority-data}' \
| base64 -d > /tmp/new-cluster-ca.crt
On the Vault Cluster
Step 4 — Switch context and copy files:
aws eks update-kubeconfig --region us-east-1 --name your-vault-cluster
kubectl cp /tmp/sa-token.txt vault/vault-0:/tmp/sa-token.txt
kubectl cp /tmp/new-cluster-ca.crt vault/vault-0:/tmp/new-cluster-ca.crt
Step 5 — Register the new cluster in Vault:
kubectl exec -n vault vault-0 -- vault write \
auth/kubernetes-your-new-cluster-name/config \
kubernetes_host="https://NEW-CLUSTER-API-SERVER" \
kubernetes_ca_cert=@/tmp/new-cluster-ca.crt \
token_reviewer_jwt=@/tmp/sa-token.txt \
disable_iss_validation=true
# Test the login — must return a valid token
kubectl exec -n vault vault-0 -- vault write \
auth/kubernetes-your-new-cluster-name/login \
role="external-secrets-role" \
jwt=@/tmp/sa-token.txt
Back on the New Cluster
Step 6 — Verify:
aws eks update-kubeconfig --region us-east-1 --name your-new-cluster
kubectl get clustersecretstore
# STATUS must show: Valid — True
The cluster is now connected. It can pull any secret it has permission for.
Part 3 — Adding a New App Secret
Repeat these 5 steps for every new app. No policy changes needed.
Step 1 — Store the Secret in Vault UI
Go to https://vault.yourdomain.com and log in.
Navigate to Secrets Engines → secret/ → Create secret
For non-sensitive config (URLs, feature flags, ports):
- Path:
mycompany/stg/backend/normal/payments-service
For sensitive credentials (passwords, API keys, tokens):
- Path:
mycompany/stg/backend/critical/payments-service
Toggle JSON on and paste your key-value pairs. Save.
Step 2 — Create an ExternalSecret File
# apps/backend/payments-service.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: payments-service
namespace: backend
spec:
refreshInterval: "5m"
secretStoreRef:
name: vault-backend # do not change
kind: ClusterSecretStore
target:
name: payments-service-secret
creationPolicy: Owner
dataFrom:
- extract:
key: mycompany/stg/backend/normal/payments-service
- extract:
key: mycompany/stg/backend/critical/payments-service
Step 3 — Push to Manifest Repo
git add apps/backend/payments-service.yaml
git commit -m "add payments-service external secret"
git push
ArgoCD picks it up and applies it. ESO creates the Kubernetes secret automatically.
Step 4 — Update the App Deployment
# In your deployment or values.yaml
envFrom:
- secretRef:
name: payments-service-secret
Step 5 — Verify
kubectl get externalsecret payments-service -n backend
# READY: True STATUS: SecretSynced
kubectl get secret payments-service-secret -n backend
# Should show the secret with all your keys
From this point, if you update a secret in Vault, the K8s secret updates itself within 5 minutes. No restarts. No manual commands.
Force sync if you want it immediately:
kubectl annotate externalsecret payments-service -n backend \
force-sync=$(date +%s) --overwrite
Day-2 Operations
Upgrading Vault
Always restart followers first, leader last:
helm upgrade --install vault hashicorp/vault \
--namespace vault --values helm-values.yaml
kubectl delete pod vault-2 -n vault # wait 1/1 Ready
kubectl delete pod vault-1 -n vault # wait 1/1 Ready
kubectl delete pod vault-0 -n vault # wait 1/1 Ready
Vault Pod Crash
Nothing to do. KMS auto-unseals on restart.
kubectl get pods -n vault -w
kubectl exec -n vault vault-0 -- vault status
# Sealed: false ← auto-unsealed
Recover Root Token (if lost)
aws secretsmanager get-secret-value \
--secret-id vault/recovery-keys \
--region us-east-1 \
--query 'SecretString' \
--output text | jq -r '.root_token'
Check All Secret Sync Status
kubectl get externalsecret --all-namespaces
# All should show: SecretSynced
What to Watch Out For
Save the root token immediately after init. If you lose it and your recovery keys, recovering Vault is very painful. Store it in AWS Secrets Manager before doing anything else.
Test IRSA before deploying Vault. Most auto-unseal failures come from IRSA being misconfigured. Run the STS test in Step 5 and confirm it returns the right account.
The ClusterSecretStore uses a Vault token. If that token expires, all syncs stop silently. Use a long-lived token or a renewable one, and monitor it.
TLS cert expiry reminder. The self-signed cert is valid 10 years but put a calendar reminder for year 9.
One Vault token per cluster for ESO. Each cluster's ESO authenticates via Kubernetes auth, not the root token. The root token stored in the vault namespace is only for initial setup and management.
Quick Reference
| What | How |
|---|---|
| Vault UI | https://vault.yourdomain.com |
| Add a namespace | Edit ALLOWED_NAMESPACES in vault-namespace-policies.yaml, re-apply |
| Add a user | Edit USERS in vault-user.yaml, re-apply |
| Add an org or env | Edit ORGS, NONPROD_ENVS, NAMESPACES in vault-rbac.yaml, re-apply |
| Onboard new cluster | Follow Part 2 above |
| Add new app secret | Follow Part 3 above |
| Check cluster status | vault operator raft list-peers |
| Check secret sync | kubectl get externalsecret --all-namespaces |
| Force resync | kubectl annotate externalsecret <name> -n <ns> force-sync=$(date +%s) --overwrite |
| Recover root token | AWS Secrets Manager → vault/recovery-keys |
Wrapping Up
This setup took a few days to get right but runs completely on its own now. The things that made the biggest difference:
KMS auto-unseal — zero incidents from sealed Vault after pod restarts.
ESO — teams stop asking DevOps to "copy the secret to this namespace". It just appears.
The path structure — when every secret follows the same pattern, writing policies and onboarding new namespaces is trivial.
RBAC roles — four roles covers every real use case. Developers get what they need, production credentials stay protected.
If you are running workloads on EKS and secrets are scattered across config maps, environment variables, and Git — this stack gives you one clean place to manage all of it.
Drop any questions in the comments. Happy to go deeper on any part of this.
Tags: DevOps, Kubernetes, HashiCorp Vault, AWS EKS, Secrets Management, Cloud Security, IRSA, External Secrets Operator



