Skip to content
Hogin Hogin
Go back

External Secrets Operator: Vault secrets in Kubernetes without copy-paste

10 мин чтения

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 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.

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.

Vault stays the source of truth, ESO syncs the value into the cluster via the reference in the ExternalSecret

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:

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.

refreshInterval polls Vault on a schedule; the Secret updates, but the pod only picks up the new value via a volume re-read or an explicit rollout

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:

One comparison table

Manual Secret / Helm valuesSOPS + gitExternal Secrets Operator
Source of truthcopy in the clusterencrypted copy in gitexternal manager (Vault etc.)
Rotationrecreate the Secret by handrebuild and commit by handautomatic, via refreshInterval
What leaks if git is compromisedplaintext secretencrypted blob (needs the key)just a path reference, not the value
Access to the secretanyone with kubectl get secretanyone with the decryption keylimited by the manager’s policy + K8s RBAC
Switching providersrewrite every manifestrewrite the encryption pipelinechange 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.


Share this post:

Next Post
Trivy in CI: catch vulns and generate an SBOM before prod