A secret in Kubernetes is a Secret object sitting in etcd next to your deployments. Someone has to put it there: manually via kubectl create secret, via kubectl apply from an encrypted file in git, via a template in a Helm chart. In every one of those scenarios, someone at some point copied a value out of Vault into a manifest — and from that moment the secret has a second copy living its own life. External Secrets Operator removes the human from that chain: the cluster pulls the secret from an external manager itself and keeps it in sync, with no manual copies and no secrets in git at all.
Table of contents
Open Table of contents
- SOPS solves the wrong half of the problem
- The model: SecretStore, ClusterSecretStore, and ExternalSecret
- Providers: not just Vault
- Rotation: refreshInterval and what happens when a secret changes
- RBAC: who has access to the manager
- One comparison table
- What you need to set up ESO with Vault
- How to verify it works
- Bottom line
SOPS solves the wrong half of the problem
The most popular answer to “secrets in Kubernetes” is SOPS: a file with the secret gets encrypted before commit, and a GitOps controller decrypts it on apply. It’s a working scheme, and it honestly closes one real risk — a secret in plaintext in git history.
But it says nothing about the source of truth. The secret still lives in the repo, just encrypted. If you already have Vault or a cloud Secret Manager — and most teams running more than one cluster do — you end up with two independent places to change a value on rotation: first in the manager, then by hand rebuilding and committing the encrypted file. Sooner or later they drift apart: someone rotates a password in Vault and forgets the SOPS file, or the other way around.
External Secrets Operator (ESO) comes at it from the other side. Instead of “encrypt the secret and put it in git,” it’s “the secret lives only in the manager, and the cluster pulls it from there.” Git ends up holding not the secret but a reference to it: a path in Vault, a parameter name in AWS Secrets Manager. A reference isn’t secret — leaking it gives you nothing without access to the manager itself.
The model: SecretStore, ClusterSecretStore, and ExternalSecret
ESO is built around three CRDs, and understanding them is the entire prerequisite.
SecretStore— describes a connection to one secret manager, scoped to a single namespace: the Vault address, the auth method, the AWS region. Namespaced, meaning a team can set up its own store in its own namespace without touching anyone else’s.ClusterSecretStore— the same thing, but cluster-wide: one store that anyExternalSecretin any namespace can reference (restricted vianamespaceSelectorif needed). This is usually the practical option — a platform team sets up oneClusterSecretStorefor Vault, and everyone else just points at it.ExternalSecret— a declaration that says “take this path from the store and materialize it as a k8sSecretunder this name.” This is the object a developer actually sees in their manifest — it reads like an ordinary dependency, not like anything special.
The operator runs a loop: read the ExternalSecret, go to the manager through the SecretStore, fetch the value, and create (or update) a regular Secret in the same namespace. For a pod mounting that Secret as a volume or env, there is zero difference from a manually created kubectl create secret — all the magic happens at the controller level, not at the API surface the workload sees.
Providers: not just Vault
ESO isn’t a Vault-specific tool — it’s a general abstraction layer with dozens of providers behind one API. Only SecretStore.spec.provider changes; the shape of ExternalSecret stays the same:
- HashiCorp Vault — KV v2, auth via Kubernetes ServiceAccount token, AppRole, or JWT.
- AWS Secrets Manager / SSM Parameter Store — auth via IRSA (IAM Roles for Service Accounts), no static keys in the cluster.
- GCP Secret Manager and Azure Key Vault — the same, via Workload Identity.
- 1Password, Doppler, Bitwarden, and roughly three dozen more providers — for teams whose source of truth isn’t a corporate Vault but a SaaS manager.
The practical upshot: if the company migrates from a self-hosted Vault to a cloud Secret Manager tomorrow, one ClusterSecretStore changes, not hundreds of ExternalSecret manifests across every service — they reference the store by name, not the provider directly.
Another detail that saves time once you have a lot of keys: you don’t have to list every key under data by hand. dataFrom.extract pulls every key-value pair at a given path in one block — handy when a single Vault path holds a whole bundle of related values (say, the full credential set for one database) and you don’t want to keep a field list in sync in two places.
Rotation: refreshInterval and what happens when a secret changes
The key difference from “copied once” is ExternalSecret.spec.refreshInterval. The operator doesn’t pull the secret once at object creation — it polls the manager on the given interval (an hour by default, but teams usually set one to fifteen minutes depending on sensitivity) and updates the k8s Secret if the source value changed.
A detail that trips people up the first time: updating the Secret does not restart the pod automatically. Kubernetes itself doesn’t watch for Secret changes and doesn’t restart consumers — that’s true of ESO and of any other way of updating a Secret. If an application only reads environment variables at process start, a new value shows up in the pod as a file (when mounted as a volume) almost immediately, but as an env var only after the next restart. The practice is either to mount the secret as a volume and re-read the file explicitly in the app, or to run Reloader or an equivalent that watches the Secret’s hash and triggers a rollout on change.
ExternalSecret.spec.target.deletionPolicy deserves a separate mention — what happens to the k8s Secret if the ExternalSecret itself gets deleted. The default is Retain (the secret stays), but there’s Delete for honest-cleanup scenarios.
RBAC: who has access to the manager
The operator is the single point that holds credentials to Vault or AWS Secrets Manager, and that’s both a convenience and a risk that needs deliberate limiting:
- ESO’s auth to Vault normally goes through the Kubernetes auth method — Vault trusts the ServiceAccount token of the ESO pod rather than a static token stored in a Secret. The token is short-lived; nothing long-lived sits in the cluster.
- The Vault policy bound to that ServiceAccount should grant access only to the paths actually needed by the cluster — not
secret/*, but concrete prefixes per environment and team. - If several teams share one
ClusterSecretStore, anExternalSecretin someone else’s namespace physically cannot read someone else’s path — not because Kubernetes forbids it, but because the Vault policy for that ServiceAccount doesn’t allow that path. Least privilege lives on the secret manager’s side, not on Kubernetes RBAC.
One comparison table
| Manual Secret / Helm values | SOPS + git | External Secrets Operator | |
|---|---|---|---|
| Source of truth | copy in the cluster | encrypted copy in git | external manager (Vault etc.) |
| Rotation | recreate the Secret by hand | rebuild and commit by hand | automatic, via refreshInterval |
| What leaks if git is compromised | plaintext secret | encrypted blob (needs the key) | just a path reference, not the value |
| Access to the secret | anyone with kubectl get secret | anyone with the decryption key | limited by the manager’s policy + K8s RBAC |
| Switching providers | rewrite every manifest | rewrite the encryption pipeline | change one SecretStore |
What you need to set up ESO with Vault
This assumes a working Vault with KV v2 enabled and the Kubernetes auth method already configured for the cluster. From there, three steps: install the operator, set up a ClusterSecretStore, declare an ExternalSecret.
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
-n external-secrets --create-namespace
A ClusterSecretStore for Vault via Kubernetes auth:
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "https://vault.internal:8200"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "eso-reader"
serviceAccountRef:
name: "external-secrets"
namespace: "external-secrets"
An ExternalSecret that materializes the value at secret/data/prod/api into a native Secret called api-credentials:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: api-credentials
namespace: prod
spec:
refreshInterval: 5m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: api-credentials
creationPolicy: Owner
data:
- secretKey: DB_PASSWORD
remoteRef:
key: prod/api
property: db_password
- secretKey: API_TOKEN
remoteRef:
key: prod/api
property: api_token
A pod consumes the result as an ordinary Secret — no special API for the consumer:
envFrom:
- secretRef:
name: api-credentials
How to verify it works
Check that the object synced and that the value in the cluster actually matches Vault:
kubectl get externalsecret api-credentials -n prod
# READY True — sync succeeded
kubectl get secret api-credentials -n prod -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
Check rotation: change the value in Vault, wait out the refreshInterval, confirm the Secret updated (by resourceVersion or by the value itself), and confirm a pod-restart mechanism is in place — otherwise the new value lands in the cluster but never reaches the process consuming it.
If an ExternalSecret is stuck in a state other than READY True, kubectl describe externalsecret shows the reason in status.conditions — usually an expired ServiceAccount token, a wrong Vault path, or a policy that doesn’t grant access to it. For monitoring at scale, the operator exposes Prometheus metrics (externalsecret_sync_calls_total, externalsecret_sync_calls_error) worth alerting on — a rise in sync errors means part of the cluster is about to see a stale secret, even while everything still looks green.
Bottom line
External Secrets Operator doesn’t add another place to store a secret — it removes the extra copies. The single source of truth stays in Vault or a cloud Secret Manager, and Kubernetes gets the current value by subscription through SecretStore/ExternalSecret, with scheduled rotation and RBAC that lives on the manager’s side. The cost is installing the operator and setting up one store; the payoff is that a secret physically can’t drift between git, the cluster, and the manager, because the copy in the cluster is always derived, never independent.