External Secrets Operator: syncing Vault KV v2 into Kubernetes
Published: 2026-02-13
External Secrets Operator (ESO) bridges HashiCorp Vault and Kubernetes. Instead of sealing secrets manually with kubeseal or templating them in CI, you describe what you want from Vault and ESO creates the Kubernetes Secret automatically, refreshing it on a schedule.
This post covers the full setup: install, ClusterSecretStore configuration, ExternalSecret examples for common patterns (single key, full path extraction, registry credentials), and the debugging workflow when syncs fail.
Install
ESO is deployed via Helm through FluxCD:
yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: external-secrets
spec:
interval: 1h
chart:
spec:
chart: external-secrets
version: "0.14.4"
sourceRef:
kind: HelmRepository
name: external-secrets
namespace: flux-system
values:
installCRDs: true
replicaCount: 1
serviceMonitor:
enabled: true
interval: 60s
labels:
release: kube-prom-stack
installCRDs: true is fine for single-cluster installs. For multi-cluster, manage CRDs separately to avoid version conflicts when upgrading ESO across different clusters at different times.
After install, check that the CRDs are present:
bashkubectl get crd | grep external-secrets
You should see clustersecretstores.external-secrets.io, externalsecrets.external-secrets.io, and others.
ClusterSecretStore: connect ESO to Vault
The ClusterSecretStore is a cluster-scoped resource. It holds the connection and auth config for Vault:
yamlapiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-infra
spec:
provider:
vault:
server: "http://vault.vault.svc.cluster.local:8200"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
serviceAccountRef:
name: "external-secrets"
namespace: "external-secrets"
server — the Vault address. Using the internal service DNS is better than the ingress hostname — no TLS, no external round-trip, no dependency on ingress being up.
path: "secret" — the KV mount path. If you mounted at a different path (e.g., vault secrets enable -path=kv kv-v2), update this.
version: "v2" — must match your KV version. KV v2 and v1 have different API paths; using the wrong version results in "secret not found" errors.
role: "external-secrets" — the Vault Kubernetes auth role that ESO authenticates with.
Create the Vault role
bashvault write auth/kubernetes/role/external-secrets \
bound_service_account_names=external-secrets \
bound_service_account_namespaces=external-secrets \
policies=read-secrets \
ttl=1h
And the policy:
hcl# read-secrets.hcl
path "secret/data/*" {
capabilities = ["read"]
}
path "secret/metadata/*" {
capabilities = ["read", "list"]
}
bashvault policy write read-secrets read-secrets.hcl
ExternalSecret: pull a specific key from Vault
yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: my-app-db-password
namespace: app
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-infra
kind: ClusterSecretStore
target:
name: my-app-db-password
creationPolicy: Owner
data:
- secretKey: password # key name in the K8s Secret
remoteRef:
key: secret/data/app/db # Vault KV v2 path
property: password # field name in Vault
ESO creates a Secret named my-app-db-password in the app namespace with data.password populated from Vault. Every refreshInterval it re-reads the value and updates the Secret if it changed.
The KV v2 path format
In KV v2, the API path differs from the CLI path:
- CLI:
vault kv get secret/app/db(nodata/prefix) - API/ESO:
secret/data/app/db(withdata/prefix)
This trips up almost everyone the first time. If you see "secret not found" from ESO but vault kv get works fine from the command line, it's usually a path format mismatch.
Syncing all keys from a Vault path
If you have a Vault KV path with many fields, use dataFrom instead of enumerating each one:
yamlspec:
dataFrom:
- extract:
key: secret/data/app/config
This creates a Kubernetes Secret with one entry per field in the Vault secret. If the Vault secret has username, password, and host fields, the K8s Secret will have all three.
Remapping key names
If the field names in Vault don't match what your app expects in the Kubernetes Secret:
yamlspec:
data:
- secretKey: DB_PASSWORD # K8s Secret key
remoteRef:
key: secret/data/app/db
property: password # Vault field name
- secretKey: DB_HOST
remoteRef:
key: secret/data/app/db
property: host
Syncing Docker registry credentials
Image pull secrets have a special format. ESO can construct them directly:
yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: registry-creds
namespace: app
spec:
refreshInterval: 24h
secretStoreRef:
name: vault-infra
kind: ClusterSecretStore
target:
name: registry-creds
creationPolicy: Owner
template:
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: |
{"auths":{"registry.example.com":{"auth":"{{ .auth | b64enc }}"}}}
data:
- secretKey: auth
remoteRef:
key: secret/data/registry
property: auth
Or if you store the full dockerconfigjson in Vault:
yamlspec:
data:
- secretKey: .dockerconfigjson
remoteRef:
key: secret/data/registry
property: dockerconfigjson
target:
template:
type: kubernetes.io/dockerconfigjson
creationPolicy: Owner vs Merge
Owner (recommended): ESO owns the Secret. If the ExternalSecret is deleted, the Secret is deleted too. The Secret cannot be modified manually — ESO will overwrite changes on the next refresh.
Merge: ESO merges keys into an existing Secret. Useful when a Helm chart creates the Secret and you're only injecting additional keys from Vault. The existing keys in the Secret are preserved.
None: ESO does not manage the Secret lifecycle at all — it only creates it if it doesn't exist, never updates or deletes.
Comparing ESO vs SealedSecrets
| ESO | SealedSecrets | |
|---|---|---|
| Secret source | Vault, AWS SM, GCP SM, etc. | Git (encrypted) |
| Rotation | Automatic on schedule | Manual re-seal |
| Audit trail | In Vault | In Git |
| Air-gapped | Needs Vault access | Git only |
| Re-seal needed on cluster rebuild | No | Yes |
| Works without Vault | No | Yes |
| Initial bootstrap | Needs Vault + ESO first | Works from scratch |
We use both: SealedSecrets for bootstrap (kubeconfigs, initial registry creds, Vault init secrets) and ESO for application secrets that live in Vault and rotate frequently. The rule: anything that ESO itself needs to start (Vault address, auth config) should be a SealedSecret; everything else should be an ExternalSecret.
Debugging
bash# Check if ESO successfully synced
kubectl get externalsecret -n app
# See last sync status and error
kubectl describe externalsecret my-app-db-password -n app
# Force immediate re-sync
kubectl annotate externalsecret my-app-db-password \
force-sync=$(date +%s) -n app --overwrite
The force-sync annotation trick is useful when you've updated a Vault value and don't want to wait for the next refresh interval.
Checking ClusterSecretStore health
bashkubectl get clustersecretstore vault-infra
kubectl describe clustersecretstore vault-infra
A healthy ClusterSecretStore shows Valid: True. If it shows Invalid, the most common causes:
- Vault is sealed — check
vault status - Auth role is misconfigured — check with
vault read auth/kubernetes/role/external-secrets - Wrong Vault address — verify
serverfield in the ClusterSecretStore
Common error messages
| Error | Cause |
|---|---|
could not find key ... |
property field in remoteRef doesn't exist in the Vault secret |
secret not found |
Wrong path format (forgot data/ for KV v2) |
permission denied |
ESO's Vault role doesn't have read permission for this path |
connection refused |
Vault is sealed or the server URL is wrong |
ESO with multiple Vault namespaces
If you're using Vault Enterprise with namespaces, add the namespace to the ClusterSecretStore:
yamlspec:
provider:
vault:
server: "http://vault.vault.svc.cluster.local:8200"
namespace: "admin/dev" # Vault Enterprise namespace
path: "secret"
version: "v2"
The path is relative to the namespace — don't include the namespace prefix in the path.