Tools · External Secrets & the secrets patterns

External Secrets & the secrets patterns

The External Secrets Operator (ESO) is a Kubernetes controller that reads secret values out of a real secret manager — HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, 1Password — and writes them into ordinary Kubernetes Secret objects, continuously. It exists to solve the one problem that GitOps cannot solve on its own: your whole desired state is supposed to live in Git, but the database password absolutely must not. ESO's answer is that only a reference ever goes in Git — a pointer saying “fetch prod/checkout/db from Vault into a Secret called checkout-db” — while the value itself lives, rotates, and is audited somewhere Git can never see.

☺ Explain it like I’m 10

Imagine your class has a big shared notebook where you write down exactly how the classroom should be arranged — everyone can read it, and it keeps every page forever. That’s Git. Now imagine you also have a house key. You must never write the key’s shape in the notebook, because the notebook is public and permanent. So instead you write: “the key is in the safe at the front office, in the drawer labelled checkout.” A trusted helper reads that note, walks to the office, opens the safe with their own badge, brings back the key, and puts it in a little box in your classroom. If the office changes the key, the helper goes and swaps it. The note in the notebook never changes, and the notebook never, ever contains the key. The helper is the External Secrets Operator.

🐢Your host for this topic: Timmy the Turtle — slow, careful, and constitutionally unable to walk past a base64 blob without asking “so who exactly can read this, and when was it last rotated?”

What it is and the problem it solves

☺ Like you’re 10: It’s a helper that fetches real passwords from a locked safe and puts them where your app can reach them — so the password never has to be written in the public notebook.

Kubernetes has a Secret object, and at first glance the problem looks solved. It isn’t, for two independent reasons. First, a native Secret’s data field is base64-encoded, not encrypted — base64 is a transport encoding a ten-year-old can reverse, so anyone with read access to the object, or to an etcd backup, has the plaintext. Second, and more sharply for platform teams: a Secret is a YAML manifest, and GitOps says all manifests live in Git. Commit it and the password is now in permanent, cloneable, un-deletable history.

Why “just don’t commit it” fails

The naive workaround is to keep Secrets out of Git and have a human kubectl apply them by hand. This breaks everything good about reconciliation: the cluster now contains state nobody declared, a rebuilt cluster comes back subtly broken, and “who set this value and when?” has no answer. You have traded a leak risk for an availability and auditability risk. The real requirement is a way for a secret to be fully declared in Git while its value is not.

The reference/value split

That is the idea the whole page turns on. Split a secret into two halves: the reference (which secret, from where, into what Kubernetes object, refreshed how often) and the value (the actual bytes). The reference is boring metadata — commit it, review it, diff it, roll it back. The value lives in a purpose-built store that does access control, versioning, audit logging and rotation properly. ESO is the machinery that joins the two at runtime, inside the cluster, where nobody is watching.

◆ Key idea

ESO is reference-only: nothing encrypted or sensitive ever enters your repository, not even as ciphertext. That is what makes it the strongest of the three GitOps-safe patterns for rotation — when the value changes in Vault, no commit, no re-encryption and no PR is needed. The Git history stays permanently free of secret material, which also means a compromised repo mirror leaks nothing.

Where it fits in a platform

☺ Like you’re 10: It sits in the “keep things safe” part of the platform, and it hands its results to almost everything else.

ESO is a control-plane add-on in the security and configuration domain — cluster infrastructure, not application code. It is installed the same way as any other platform add-on (a Helm chart, reconciled from the config repo by Argo CD or Flux), and it produces something every workload already understands: a native Secret. That output contract is why it integrates with everything without anything having to know it exists.

Neighbouring tools

Think of it as sitting beside cert-manager, which solves the same shape of problem for a different asset — cert-manager materialises certificates into Secrets on a renewal schedule; ESO materialises credentials into Secrets on a refresh schedule. Both are consumed by ordinary Deployments. Upstream of ESO sit the stores themselves and, increasingly, the infrastructure control planes that create them: a Crossplane claim that provisions an RDS instance writes its connection secret somewhere, and ESO is often what carries it into the app namespace. Alongside sit the policy engines — Kyverno or Gatekeeper — which you use to forbid the anti-pattern (a bare committed Secret) that ESO makes unnecessary.

CNPE domain relevance

External Secrets is not on the CNPE exam tool list, and neither is Vault. But secrets handling underpins the GitOps & Continuous Delivery domain and sits squarely inside the Security & Policy domain, so the exam can absolutely ask you conceptually which pattern keeps a secret out of Git, or why a committed Secret is a finding. Treat this page as the practical companion to the lesson at Secrets & Workload Identity: know the patterns cold, and know that only native Kubernetes Secret manifests are the sort of thing you might be asked to write.

How it works — architecture and CRDs

☺ Like you’re 10: There are two kinds of note: one says which safe and which badge, the other says which drawer and which box to put it in.

ESO installs as a small set of Deployments (usually in the external-secrets namespace): the controller itself, a validating webhook, and a cert-controller that keeps the webhook’s certificates fresh. It introduces five custom resources you will meet day to day, and the split between them is the thing worth understanding.

SecretStore and ClusterSecretStore

A SecretStore answers “where do I fetch from, and how do I authenticate?” It names a provider (vault, aws, gcpsm, azurekv, onepassword, and many more) and the auth method to use with it. It is namespaced, so a team can own the connection to their own path. A ClusterSecretStore is the identical spec at cluster scope — one platform-owned connection that any namespace can reference. Note the sharp edge: a ClusterSecretStore is a shared credential, so whatever it can read, every namespace that may reference it can read. Scope its permissions in the store, not just in Kubernetes.

ExternalSecret and ClusterExternalSecret

An ExternalSecret answers “which keys, into which Secret, how often?” It points at a store, lists what to fetch, names the Kubernetes Secret to create, and sets a refreshInterval. Fetching comes in two flavours: data maps individual remote keys to individual Secret keys (precise, explicit), while dataFrom pulls an entire remote object at once (with extract) or every secret matching a pattern (with find). A ClusterExternalSecret is a template that fans a single ExternalSecret out into many namespaces selected by label — the standard way to push a shared image-pull secret or a common CA bundle everywhere at once.

PushSecret — the arrow reversed

PushSecret goes the other direction: it takes a Secret that already exists in the cluster and writes it into the external store. That sounds backwards until you meet the case it serves — an operator generated a credential in-cluster (a database operator minting a password, say) and you want the canonical store to hold the authoritative copy for other consumers and for humans. Use it deliberately and sparingly; the default direction of travel should be inward.

Git repo ExternalSecret reference only cluster 🐢 External Secrets Operator SecretStore auth · refreshInterval Secret (native) checkout-db Pod mounts it Secret store Vault · AWS · GCP Azure · 1Password GitOps applies fetch value the plaintext never touches Git — only the pointer does

The resources you will actually write

☺ Like you’re 10: Two short notes and you’re done: one describing the safe, one describing what to fetch from it.

Three realistic examples. Read them as a pair-plus-one: the store, the secret, and the fan-out.

A ClusterSecretStore for Vault (Kubernetes auth)

This is the shape you will write most often in production: Vault’s Kubernetes auth method, where ESO presents its own ServiceAccount token and Vault validates it against the cluster’s token reviewer — so there is no static Vault token stored anywhere. version: v2 selects Vault’s KV version 2 engine, which is versioned and soft-deletes rather than overwrites. One portability note: current ESO serves these types under external-secrets.io/v1, but older installations still serve the same resources under the deprecated v1beta1 — run kubectl api-resources --api-group=external-secrets.io before copying a manifest between clusters.

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: vault-prod
spec:
  provider:
    vault:
      server: https://vault.acme.internal:8200
      path: kv                     # the KV mount point
      version: v2                  # KV v2 — versioned, soft-delete
      auth:
        kubernetes:
          mountPath: kubernetes    # Vault auth mount
          role: eso-prod           # Vault role bound to the SA below
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets

The equivalent for AWS Secrets Manager swaps the provider block for aws: with service: SecretsManager (or ParameterStore), a region, and a jwt auth stanza pointing at a ServiceAccount annotated for IRSA — which means, again, no stored access key.

An ExternalSecret with data, dataFrom and templating

Now the consumer side. Note four things: refreshInterval controls how often ESO re-reads the store; target.template lets you compose a value (here, a full DSN) rather than passing raw fields to the app; dataFrom.extract flattens an entire remote JSON object into Secret keys; and the easily-missed mergePolicy — a template defaults to Replace, meaning the rendered keys become the only keys in the resulting Secret, so if you want the fetched keys to survive alongside the composed one you must ask for Merge.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: checkout-db
  namespace: checkout
spec:
  refreshInterval: 1h              # re-read the store on this cadence
  secretStoreRef:
    name: vault-prod
    kind: ClusterSecretStore
  target:
    name: checkout-db              # the Secret ESO creates & owns
    creationPolicy: Owner          # ESO owns it; deleting the ES deletes it
    template:
      engineVersion: v2
      mergePolicy: Merge           # keep the fetched keys AND add the templated one
      data:
        # build a connection string from the fetched fields
        DATABASE_URL: "postgres://{{ .username }}:{{ .password }}@db.prod:5432/checkout"
  data:
    - secretKey: username          # key inside the Kubernetes Secret
      remoteRef:
        key: prod/checkout/db      # path inside the store
        property: username         # field within that object
    - secretKey: password
      remoteRef:
        key: prod/checkout/db
        property: password
  dataFrom:
    - extract:
        key: prod/checkout/stripe  # pull every field of this object in

A ClusterExternalSecret fanned out by namespace label

One object, many namespaces — the platform-team pattern for something every tenant needs. Add the label to a namespace and the Secret appears; remove it and the Secret goes away.

apiVersion: external-secrets.io/v1
kind: ClusterExternalSecret
metadata:
  name: registry-pull
spec:
  externalSecretName: registry-pull
  refreshTime: 1h
  namespaceSelectors:
    - matchLabels:
        acme.io/tenant: "true"     # every tenant namespace gets it
  externalSecretSpec:
    refreshInterval: 1h
    secretStoreRef: { name: vault-prod, kind: ClusterSecretStore }
    target:
      name: registry-pull
      template:
        type: kubernetes.io/dockerconfigjson
        data:
          .dockerconfigjson: "{{ .dockerconfig | toString }}"
    data:
      - secretKey: dockerconfig
        remoteRef: { key: platform/registry, property: dockerconfigjson }
🦆 Dot’s-eye view

“I don’t know what Vault is and I’ve never logged into it. I add eight lines of YAML to my app folder saying which secret name I want, open a PR, and by the time it merges there’s a Secret in my namespace and my Deployment’s envFrom just works. When security rotated the database password last month I found out from the changelog — nothing in my repo changed at all.”

Day-to-day commands

☺ Like you’re 10: There’s no special app to learn — you check on it with the same kubectl you already use.

ESO has no CLI of its own. Everything is kubectl against its CRDs, which is a genuine design virtue: it means the tool is debuggable with the skills the exam already tests.

Installing and inspecting

# install (in real life, do this from your config repo via Argo CD / Flux)
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace

# what CRDs did it bring?
kubectl api-resources --api-group=external-secrets.io

# is the store reachable and authenticated?
kubectl get clustersecretstore vault-prod -o wide
kubectl describe clustersecretstore vault-prod   # look for Ready / ValidationFailed

Debugging a secret that will not appear

# the status and its conditions hold the real error
kubectl -n checkout get externalsecret checkout-db
kubectl -n checkout describe externalsecret checkout-db

# did the Secret land, and does it have the keys you expect?
kubectl -n checkout get secret checkout-db -o jsonpath='{.data}' | tr ',' '\n'

# force an immediate re-sync without waiting for refreshInterval
kubectl -n checkout annotate externalsecret checkout-db \
  force-sync="$(date +%s)" --overwrite

# operator logs — auth failures and provider errors surface here
kubectl -n external-secrets logs deploy/external-secrets --tail=100

The Vault side

# assumes a KV v2 engine mounted at kv/ — matching path: kv in the store above
vault kv put   kv/prod/checkout/db username=checkout password='s3cr3t'
vault kv get   kv/prod/checkout/db     # KV v2 keeps versions
vault kv get -version=3 kv/prod/checkout/db

# dynamic credentials: Vault mints a short-lived DB user with a lease
vault read database/creds/checkout-role
vault lease revoke -prefix database/creds/

That last pair is the point of Vault that people miss. A dynamic secret is not stored at all — Vault creates a fresh database user on demand, hands it back with a lease, and revokes it when the lease expires. There is no long-lived password to steal, and “rotation” becomes automatic rather than an event.

Gotchas and failure modes

☺ Like you’re 10: The helper fetches the new key — but nobody tells your app to go and look in the box again.

Rotation does not restart your pods

This is the single most common surprise. ESO updates the Secret; it does not restart anything. A Secret consumed via env or envFrom is read once at container start and is frozen for the life of the process — the pod keeps using the old password forever. (A Secret mounted as a volume does get updated in place by the kubelet — after a delay of roughly the kubelet sync period plus its cache TTL, so on the order of a minute or two — but only if the app re-reads the file, and never at all if the volume was mounted with subPath, which pins the file at its original contents.) The two standard fixes: run Reloader, a controller that watches Secrets and rolls the Deployments that reference them; or have your rendering put a checksum of the secret into a pod-template annotation so the change itself triggers a rollout. Symptoms of getting this wrong look exactly like an application bug — see Workload triage.

⚠ Watch out

Three more that bite. Native Secrets are still not encrypted — ESO produces a plain base64 Secret, so you must also enable etcd encryption at rest (ideally KMS-backed) and lock down RBAC, or you have moved the problem rather than solved it. refreshInterval is a poll: set it too low across hundreds of ExternalSecrets and you will rate-limit yourself out of AWS Secrets Manager or hammer Vault; set it too high and rotation takes hours to land. And a deleted ExternalSecret deletes the Secret under the default creationPolicy: Owner — combine that with GitOps prune and a careless folder rename takes an app down.

Auth, scope and blast radius

A ClusterSecretStore that can read the whole Vault mount is a cluster-wide skeleton key: any namespace allowed to reference it can pull any path. Scope the Vault role or IAM policy to a prefix, prefer per-team namespaced SecretStore objects for anything sensitive, and remember that RBAC on the resulting Secret is what actually protects the value once it lands. If a Secret is missing entirely, check the ExternalSecret’s conditions first, then the store’s — the failure is far more often an auth or path error than a bug. Delivery-shaped symptoms belong in Delivery triage.

Templating and type mistakes

Two small ones with outsized annoyance value. A Secret meant for an image pull must have type: kubernetes.io/dockerconfigjson and exactly the key .dockerconfigjson; get either wrong and the kubelet silently ignores it. And values pulled from a store frequently carry a trailing newline from however a human pasted them in — which breaks tokens in ways that are very hard to see. Template deliberately rather than hoping the raw bytes are clean.

Alternatives and when to choose it

☺ Like you’re 10: Three different ways to keep the key out of the public notebook — scrambled in the notebook, scrambled a different way, or not in the notebook at all.

ESO is one of three GitOps-safe patterns, and a mature platform sometimes runs two. The distinction that matters is where the secret material physically is.

The three patterns compared

DimensionSealed SecretsSOPSExternal Secrets (ESO)
Where the value livesCiphertext in GitCiphertext in GitOutside Git entirely
How it’s encryptedkubeseal encrypts to a cluster-specific public keyage/PGP or a cloud KMS key, per-valueNot encrypted in Git — only a reference is
Who decryptsThe in-cluster controller (private key never leaves)The reconciler at apply time (native Flux integration)The operator, by authenticating to the store
RotationPainful — re-seal and commit every timePainful — re-encrypt and commitFree — change it in the store, ESO picks it up
PortabilityCiphertext is bound to one cluster’s keyPortable to anyone holding the keyPortable; the store is the constant
External dependencyNone beyond the controllerNone (KMS optional)Yes — the store must be reachable
Best forSmall teams, few secrets, no secret managerFlux shops wanting self-contained reposAnything with real rotation, audit or compliance needs

Read them as a ladder. Sealed Secrets is the cheapest honest answer: kubeseal encrypts a Secret to a public key that only the target cluster’s controller can undo, so the resulting SealedSecret is safe in a public repo — but it is bound to that cluster and rotation means a commit. SOPS encrypts individual values in place, keeping the file readable and diffable, and Flux decrypts it during reconciliation; excellent ergonomics, same rotation problem. ESO removes secret material from the repo altogether, at the cost of a runtime dependency on the store.

Vault delivery: ESO vs Agent Injector vs the CSI driver

ApproachHow the app gets itCreates a K8s Secret?Trade-off
ESOA native Secret, via env or volumeYesUniversal and simple; the value does rest in etcd
Vault Agent InjectorA sidecar renders templated files into a shared memory volumeNoNever touches etcd; handles leases and renewal — but a sidecar per pod and Vault-specific
Secrets Store CSI driverA CSI volume mounts the store’s values as filesOnly if you opt into secret syncMulti-provider, no etcd copy by default; only available to mounting pods, not to things needing a real Secret

The endgame: no stored credential at all

Every pattern above is a way of moving a stored secret around more safely. The destination is not to move it but to abolish it. With workload identitySPIFFE/SPIRE issuing short-lived cryptographic identities, or IRSA and its GKE/Azure equivalents federating a pod’s ServiceAccount token to a cloud role — a workload proves who it is and receives a credential valid for minutes. There is nothing to rotate, nothing to leak, nothing to commit. Vault’s dynamic secrets are the same idea for databases. Treat ESO as the pragmatic middle of the ladder for the many systems that still demand a static password, and push everything you can to identity. The full ladder is laid out in Secrets & Workload Identity.

🐢 Timmy’s workshop · 20 min

On a kind cluster: install ESO by Helm, then run Vault in dev mode (vault server -dev) and enable the Kubernetes auth method. Write a password field to secret/demo/app (a dev-mode Vault mounts KV v2 at secret/, so set path: secret in the store), create a ClusterSecretStore and an ExternalSecret, and watch a Secret appear. Now do the experiment that teaches the lesson: run a pod with envFrom pointing at the Secret, change the password in Vault, wait for the refresh, and confirm the Secret updated while the running pod still holds the old value. Then add a checksum annotation to the pod template and watch the rollout happen. That one contrast is the whole rotation gotcha, learned permanently.

🎬 At the Platform Guild
🦊

Foxy: Our Secrets are base64 in Git. Base64 counts as encryption, right? It looks encrypted.

🐢

Timmy: It looks like a padlock drawn on a door in crayon. base64 -d. That’s the whole attack.

🦫

Benny: So we put a reference in Git and let the operator fetch the real thing from Vault. Nothing sensitive ever lands in a commit.

👺

Gizmo: Fine, fine. I’ll seal it with kubeseal instead. Then I’ll rotate it every ninety days. By hand. Committing each time. 🤑

🐢

Timmy: You will do that exactly twice, Gizmo, and then you will never do it again — and the secret will be four years old.

🤖

Recon: BEEP. I reconciled the ExternalSecret. Secret updated at 14:02. The pods are still running the password from March.

🦆

Dot: …so the rotation worked and my app is still broken? Add Reloader. Please. Today.

Exam relevance and going further

☺ Like you’re 10: You won’t be asked to run this tool in the exam — but you must be able to say why a password in Git is wrong, and what to do instead.

What to be able to do cold

Four things. One: say in one sentence why a native Secret is not secure by itself (base64 is encoding; you need encryption at rest plus RBAC). Two: name the three GitOps-safe patterns and the one-line distinction between them — Sealed Secrets and SOPS put ciphertext in Git, ESO puts only a reference. Three: write a plain Kubernetes Secret and consume it from a Deployment via envFrom and via a volume, from memory — that is the examinable manifest, drilled on Know Cold. Four: explain why updating a Secret does not update a running pod.

The documentation allowlist

Say this plainly, because it changes how you should study: during the CNPE exam the only documentation you may open is kubernetes.io/docs, kubernetes.io/blog, whatever task-specific links appear in that question’s Quick Reference box, and local man pages and /usr/share docs. external-secrets.io and HashiCorp’s Vault docs are not available to you. Neither tool is on the CNPE tool list, so no task should require either — but you cannot look up an ExternalSecret field mid-exam, and you can look up a native Secret on kubernetes.io in seconds. Spend your memorisation budget accordingly, and check the docs map for what is actually reachable.

⚖ CNPA vs CNPE — That allowlist is a CNPE-only mechanic: CNPE is hands-on, so it permits those narrow live lookups mid-task. CNPA is stricter, not looser — a fully closed-book, multiple-choice exam with zero external resources and zero lookups of any kind, so neither external-secrets.io nor kubernetes.io would be reachable there. Even so, the concept-level knowledge above — why a native Secret isn't secure by itself, and the ciphertext-in-Git vs reference-only distinction between Sealed Secrets/SOPS and ESO — is exactly the kind of thing CNPA's closed-book recall draws on.

Official resources for after the exam

The canonical sources are the ESO docs at external-secrets.io (the API and Provider sections are the ones you will live in), the source at github.com/external-secrets/external-secrets, Kubernetes’ own Secret concept page and its encrypting-data-at-rest task — both of which are on the exam allowlist and worth reading now. For the alternatives: Sealed Secrets, SOPS, and SPIFFE. Then come back to the tool landscape to see how this piece sits beside the rest, and to Security & Policy for the enforcement half of the story.

🐢 Timmy’s checkpoint

1. Why is a native Kubernetes Secret committed to Git a problem, and give both reasons. 2. What is the one-line difference between Sealed Secrets, SOPS and ESO in terms of where the secret material physically sits? 3. Which two ESO resources do you need at minimum to get a Secret into a namespace, and what question does each answer? 4. Your ExternalSecret has refreshInterval: 1h, the password rotated in Vault, the Secret shows the new value — and the app is still failing to authenticate. What happened, and name two fixes. 5. When would you choose the Vault Agent Injector over ESO? 6. During the exam, can you open external-secrets.io?

Check your answers
  1. Two reasons. Encoding: data is base64, not encrypted — anyone who can read the file or an etcd backup has the plaintext. Permanence: Git history is immutable and cloneable, so a committed secret is leaked forever and must be treated as burned even after you “remove” it.
  2. Sealed Secrets and SOPS both put encrypted secret material in the repo (differing in who holds the key: a cluster-specific controller key vs age/PGP/KMS). ESO puts no secret material in the repo at all — only a reference — and the value stays in the external store.
  3. A SecretStore or ClusterSecretStore — “where do I fetch from and how do I authenticate?” — and an ExternalSecret — “which remote keys, into which Kubernetes Secret, how often?”
  4. Updating a Secret does not restart pods. Values injected via env/envFrom are read once at container start and frozen for the process lifetime. Fixes: run Reloader to roll Deployments when a referenced Secret changes, or put a checksum of the secret in the pod-template annotations so the change forces a new rollout. (Mounting as a volume also helps, but only if the app re-reads the file.)
  5. When you want the value to never exist as a Kubernetes Secret in etcd — the sidecar renders it into a memory-backed volume — and when you need Vault-native lease renewal for dynamic credentials. The cost is a sidecar per pod and a Vault-only solution.
  6. No. The allowlist is kubernetes.io/docs, kubernetes.io/blog, the task’s Quick Reference links, and local man//usr/share docs. ESO is not on the CNPE tool list, so no task should need it — but drill the plain Secret manifest on Know Cold, because that one is fair game.