External Secrets Operator: sync Vault into Kubernetes
Keep the source of truth in Vault and let the operator materialize Kubernetes Secrets your pods can mount.
The application does not care where the database password came from; it reads DB_PASSWORD from a mounted Secret and connects. That indifference is the whole case for External Secrets Operator. The authoritative value stays in Vault, where it is versioned, audited and rotated, and the operator writes an ordinary Kubernetes Secret from it on a schedule, so a Deployment written before anyone said the word Vault keeps working unchanged. What ESO does not do is make the copy in etcd any less of a copy, and most of the decisions in this setup are about that.
ESO syncs copies into the cluster. Rotate at the source of truth.
The SecretStore is the trust boundary
apiVersion: external-secrets.io/v1kind: SecretStore # namespaced: this team's Vault role, nobody else'smetadata:name: vault-kvnamespace: paymentsspec:provider:vault:server: https://vault.acme.internal:8200path: secret # the KV mountversion: v2auth:kubernetes:mountPath: kubernetesrole: payments-eso # bound to this namespace and this service account in VaultserviceAccountRef:name: payments-esoaudiences:- vault
A SecretStore lives in one namespace and names the Vault role that namespace is allowed to use; a ClusterSecretStore is the same object cluster-wide, and belongs to the platform team for the few secrets every namespace needs. With serviceAccountRef, the operator requests a token for that service account and logs in to Vault with it, so the Vault role's bound_service_account_names and bound_service_account_namespaces are what stop the payments store from reading the billing team's paths. Leave the reference out and ESO authenticates with its own service account, which turns the operator into one identity that can read everything any store can read. The audiences list matches the audience on the Vault role; Vault 1.20 warns when a role has none, and HashiCorp's notes say the warning will not become a hard requirement, so treat it as good practice rather than an upgrade blocker.
The ExternalSecret decides what lands in etcd
apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata:name: payments-dbnamespace: paymentsspec:refreshInterval: 1h # how often ESO re-reads Vault and rewrites the SecretsecretStoreRef:name: vault-kvkind: SecretStoretarget:name: db-credentials # the Kubernetes Secret the Deployment already mountscreationPolicy: Owner # deleted with the ExternalSecretdata:- secretKey: DB_USERNAMEremoteRef:key: payments/db # secret/data/payments/db in KV v2property: username- secretKey: DB_PASSWORDremoteRef:key: payments/dbproperty: password
Each data entry is one key in the resulting Secret, so the manifest doubles as an inventory of what is now readable by anyone with get on Secrets in the namespace. dataFrom with extract copies every property under a Vault path in one line, which is convenient for a legacy service that expects a dozen variables and is exactly the wrong default for a new one, because the inventory disappears. refreshInterval is the rotation budget: a value rotated in Vault reaches etcd on the next tick, and reaches a running process only if that process re-reads the mount or is restarted; that last hop is the reloader question in rotation without an outage.
target.template is the feature that removes the last reason to keep a hand-made Secret: it renders the fetched values into whatever shape the consumer needs, a kubernetes.io/dockerconfigjson for an image pull secret, a full application.yaml for a service that only reads one file, or a tls Secret from a certificate stored in KV. The template lives in Git with the ExternalSecret and the values do not, the division GitOps wanted all along.
kubectl -n payments get externalsecret payments-dbNAME STORE REFRESH STATUS READYpayments-db vault-kv 1h SecretSynced Truekubectl -n payments get secret db-credentials -o jsonpath="{.data.DB_PASSWORD}" | base64 -dXk9…the copy is a real Secret: RBAC on secrets, encryption at rest and audit logging apply to it, not to VaultHow it fails, and what each failure looks like
ExternalSecret status conditions worth alerting on
| Symptom | Usual cause | Where to look |
|---|---|---|
READY False, SecretSyncedError, message mentions permission denied | the Vault policy does not cover the path, or the role is bound to a different namespace or service account | kubectl describe externalsecret; Vault audit log for the login and the read |
READY False, message mentions 403 at login | the service account token audience does not match the Vault role, or the role name is wrong | the audiences list and vault read auth/kubernetes/role/<name> |
| Secret exists but holds an old value | the value rotated in Vault less than refreshInterval ago, or the pod cached it at start | the status.refreshTime field; then the reloader question |
| Secret deleted, then recreated with a new resourceVersion | someone deleted the Secret by hand; creationPolicy: Owner recreates it on the next reconcile | audit log; this is working as designed |
The operator is one of three delivery patterns, and it is the right one when GitOps manifests, existing Deployments and Secret-shaped consumers (a Helm chart's existingSecret value, an ingress TLS secret) are what you have. The Vault Agent injector avoids the etcd copy at the cost of a sidecar per pod, and both depend on the same Kubernetes auth role being bound tightly. What lands in etcd, and how that copy is protected, is the subject of Secrets at rest in etcd.