Every Kubernetes cluster has the same awkward secret sitting in its API: the Secret. Base64 encoding is not encryption, anyone with read access to etcd or the API sees plaintext, and the moment you commit a Secret manifest to Git you have moved a credential from your cluster into a system designed to remember everything forever. Teams patch over this with encrypted secrets in CI, or tools that seal values until they reach the cluster, but the underlying shape of the problem stays the same: Kubernetes wants a Secret object, and your secrets manager wants to be the single place where credentials actually live.
The External Secrets Operator (ESO) resolves that tension by flipping the direction of synchronization. Instead of pushing secrets into the cluster, the cluster pulls them. Credentials stay in AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, or one of the many other supported backends, and ESO materializes them as ordinary Kubernetes Secrets that your applications consume exactly as before. No application changes, no Git-sprayed credentials, and rotation becomes a property of the backend rather than a deploy pipeline exercise.
The operator has quietly become the default answer here: an open-source project with close to seven thousand GitHub stars and a broad provider ecosystem, and the recent v2.9.0 release (August 2026) continues a steady monthly cadence that started with 2.0 in February. This post walks through the core API, the operational details that matter in production, and the patterns that keep the whole setup boring — which is exactly what you want from secrets infrastructure.
The mental model: stores, secrets, and one controller loop
ESO adds three custom resources that between them describe everything. A SecretStore describes how to talk to one external API — credentials, region, mount path, retry behavior. A ClusterSecretStore is the cluster-scoped variant, useful when a central platform team owns the Vault namespace or the AWS account and every namespace should be able to reference the same store. An ExternalSecret describes what to fetch and where to put it: which keys, from which store, landing in which Kubernetes Secret. The controller reconciles ExternalSecrets on a loop, so when a value rotates upstream, the Kubernetes Secret follows automatically.
Installation is a Helm chart away:
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets \
external-secrets/external-secrets \
--namespace external-secrets --create-namespace
Defining a store
Here is a store for AWS Secrets Manager. When the controller runs on EKS with IAM Roles for Service Accounts, no static credentials are needed at all — the provider picks up the pod’s identity, and you only pin the region and an optional role to assume:
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: aws-secrets
spec:
provider:
aws:
service: SecretsManager
region: eu-central-1
role: arn:aws:iam::123456789012:role/eso-reader
The same pattern covers HashiCorp Vault (Kubernetes auth, AppRole, or TLS cert auth), GCP Secret Manager, Azure Key Vault, 1Password, Doppler, and dozens more. One caveat worth knowing: providers without an active maintainer emit warning events on the store, and the project is actively pruning dead ones — stick to the maintained list in the stability matrix when you pick a backend, because a provider that silently stops tracking upstream API changes is a liability in the one component you cannot afford to have drift.
Fetching secrets: data, dataFrom, and templates
The spec.data field maps individual remote keys to Secret keys explicitly, which is the right choice when one upstream secret feeds several distinct environment variables:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: payments-db
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets
kind: ClusterSecretStore
target:
name: payments-db-creds
data:
- secretKey: username
remoteRef:
key: payments/prod/database
property: username
- secretKey: password
remoteRef:
key: payments/prod/database
property: password
When you want the whole upstream object verbatim, spec.dataFrom with extract pulls every property of the remote key into the target Secret, and a find variant can discover secrets by path or tag regexp — handy for teams that prefix by environment. The target.template field is where ESO earns its keep for configuration-heavy apps: it renders a Go template into the Secret, so a connection string assembled from three upstream values becomes a single file your application mounts:
spec:
target:
name: payments-config
template:
data:
config.yml: |
database:
connection: postgres://{{ .username }}:{{ .password }}@db.internal:5432/payments
Refresh policies: not everything should poll
The default refreshPolicy: Periodic re-syncs on every refreshInterval, which covers rotated database passwords. Two newer policies cover the other cases. OnChange syncs only when the ExternalSecret spec itself changes — right for certificates you reissue deliberately. CreatedOnce syncs a single time and stops, which sounds simple until you combine it with a generator, and then it becomes the cleanest way to solve an old bootstrap problem.
Generators produce values instead of fetching them — random passwords, ECR tokens, and friends. The classic Keycloak headache is the admin password that must be generated exactly once and never differ from what the application persisted on first boot. A GitOps controller pruning and re-applying the ExternalSecret would normally reset its status and trigger a fresh generation. The documented combination that survives this is CreatedOnce plus an orphaned, immutable target:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: keycloak-admin-password
spec:
length: 32
digits: 5
symbols: 5
allowRepeat: true
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: keycloak-admin
spec:
refreshPolicy: CreatedOnce
target:
name: keycloak-admin
creationPolicy: Orphan
immutable: true
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: keycloak-admin-password
creationPolicy: Orphan keeps the Secret if the ExternalSecret is deleted, and immutable: true stops any later reconcile — including one triggered by recreating the object — from overwriting the data. Together they give you a true generate-once credential inside a fully declarative workflow.
For the periodic case, syncWindows gates when refreshes may run, using cron schedules in UTC with allow or deny semantics. It is a small feature that pays off in regulated environments: deny windows during your change freeze, allow windows that keep credential updates inside business hours so a bad rotation pages a human, not a skeleton crew. Remember that windows only suppress syncs — the controller still requeues on refreshInterval — so keep the interval shorter than your smallest window or an occurrence can open and close between checks. Manual refresh is one annotation away, using a fresh timestamp value as the trigger.
What v2.9 brings
The 2.9.0 release (August 7, 2026) is characteristic of the project’s current phase: hardening over splash. The 1Password provider gained environments support, the Helm chart gained opt-in schedulerName and runtimeClassName for pods in constrained clusters, and the e2e suites grew fan-out matrix validation for the increasingly popular multi-store setups. Under the hood there are dependency pins and security bumps — a grpc-go fix and a golang.org/x/text CVE among them — plus a fix for template abuse of secret values, the kind of unglamorous change you want to read in a secrets tool’s changelog. Per the support matrix, 2.8+ targets Kubernetes 1.35–1.36, and version N is supported until N+1 ships, so plan upgrades on that rhythm.
Where this leaves the alternatives
Encrypted-in-Git approaches like SOPS and Sealed Secrets still have a place — small clusters, strict air-gapped constraints, teams without a managed secrets backend. But they keep Git as the distribution mechanism, which means rotation still flows through pull requests and every clone of the repo carries decryptable history. ESO inverts that: Git holds only references — store name, remote key — and the values live, rotate, and get audited in a system designed for exactly that. The Kubernetes Secret your app consumes is an implementation detail the operator maintains for you.
If you are still hand-syncing credentials or encrypting manifests, the migration path is gentle: install the operator, define one store, convert a single low-stakes Secret, and let it run for a week. The Ready condition on the ExternalSecret and the operator’s events give you all the observability you need to trust the loop before you point it at the credentials that actually matter.