Philipp Lehmann @PhilippTheServer

Breaking the Circular Dependency in GitOps Secret Delivery

The controller that is supposed to deliver secrets cannot also deliver its own.

The problem

Once a cluster has a secrets operator — something that reads from an external secret store and materialises a Kubernetes Secret from it — every application’s credentials can be kept out of git entirely: the manifest declares which secret it needs, and the operator fetches the value at reconcile time. That’s the appeal, and it’s real. The awkward part is how the operator itself authenticates to the secret store.

# Broken. Do not copy this — where does this Secret come from?
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
spec:
  provider:
    vault:
      server: "https://vault.example.internal:8200"
      auth:
        tokenSecretRef:
          name: vault-token          # <- this Secret has to already exist
          key: token

The SecretStore resource needs a Kubernetes Secret — a Vault token, in this case — before it can fetch anything at all. If that credential is meant to arrive the same way every other secret does, through the operator this resource configures, the resource is waiting on the very system it’s part of setting up. If instead it’s meant to arrive through GitOps like everything else, it’s a plaintext credential in the git repository — the exact thing the entire operator exists to avoid. Either path is circular, and the usual instinct — “just commit a placeholder and swap it in manually after bootstrap” — works exactly once, on the day someone remembers to do it by hand, and is invisible to whoever rebuilds the cluster next and doesn’t know that step exists.

Working through it

Not everything can arrive through the system it establishes

A circular dependency means some fact has to be true before the mechanism that would normally make it true exists. That’s not a bug to route around cleverly — it’s the literal boundary of what GitOps can automate. Something, somewhere, has to be provisioned by a process outside the GitOps loop, exactly once, and treated afterwards as an input to that loop rather than an output of it.

Draw the boundary at “credential to reach the secret store”, nowhere further in

The temptation is to solve this generally — “make bootstrapping fully hands-off” — which usually ends up smuggling a real secret into a place it doesn’t belong. The narrower, honest boundary is: everything except the one credential the secrets operator uses to authenticate to the store is GitOps-managed as normal. The operator’s own deployment, its SecretStore configuration, every ExternalSecret any application declares — all of that is ordinary git-tracked YAML. Only the credential that lets the SecretStore actually authenticate sits outside it.

That one credential is provisioned out-of-band, deliberately, once

“Out-of-band” here means a step a human or a pipeline with cluster-admin access runs directly against the cluster, not through the GitOps controller: kubectl create secret against a namespace the operator will read from, run once per cluster, and never again unless the credential rotates. This is not a workaround — it’s the actual, correct shape of the problem. A secret store credential is infrastructure bootstrap, in the same category as the cloud API token that let you create the cluster in the first place; it was never going to be one more thing GitOps delivers to itself.

Namespace and secret shape have to be predictable before the operator exists

Because this step runs before the GitOps-managed resources that depend on it, the namespace it targets and the name it gives the secret can’t be discovered from the GitOps repository — they have to be fixed, documented values that the SecretStore manifest, written afterwards, references by the same fixed names. Getting this pairing wrong — the bootstrap script creates the secret in one namespace, the SecretStore looks in another — is the single most common way this setup silently fails, and it fails quietly: the SecretStore simply reports it can’t find its credential, which looks identical to a dozen other configuration mistakes.

Rotation goes through the same door it came in

Because the credential didn’t arrive through GitOps, it doesn’t rotate through GitOps either. A runbook — or better, a scheduled job with the same narrow, direct access the bootstrap step used — has to update that one Secret in place when the credential changes. This is worth stating plainly as a limitation: the one piece of this system that isn’t self-healing through git is also the piece a compromised or expired credential would affect first.

The solution

A Vault dev server standing in for the secret store, and the External Secrets Operator reading from it, on a local kind cluster. This reproduces the actual shape of the circularity and its break: everything after the bootstrap step is ordinary, git-style YAML; only the bootstrap step itself touches the cluster directly.

kind create cluster --name secrets-bootstrap-demo

docker run -d --name vault-dev --network kind \
  -p 8200:8200 \
  -e VAULT_DEV_ROOT_TOKEN_ID=dev-only-root-token \
  hashicorp/vault:1.17.6

Seed one secret into Vault, to prove the operator can fetch something real later:

docker exec -e VAULT_ADDR=http://127.0.0.1:8200 \
  -e VAULT_TOKEN=dev-only-root-token vault-dev \
  vault kv put secret/demo-app password=hunter2-placeholder

Install the External Secrets Operator, pinned:

helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
  --version 0.10.4 \
  --namespace external-secrets --create-namespace \
  --set installCRDs=true

kubectl -n external-secrets wait --for=condition=available --timeout=300s \
  deployment/external-secrets

This is the out-of-band bootstrap step — the one command in this whole setup that is not, and cannot be, delivered through GitOps, because the resource it creates is what every GitOps-managed SecretStore afterwards depends on:

kubectl create namespace external-secrets --dry-run=client -o yaml | kubectl apply -f -
kubectl -n external-secrets create secret generic vault-token \
  --from-literal=token=dev-only-root-token

Everything from here on is a manifest a GitOps controller applies like any other:

# secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: external-secrets
spec:
  provider:
    vault:
      server: "http://vault-dev.default.svc.cluster.local:8200"
      path: secret
      version: v2
      auth:
        tokenSecretRef:
          name: vault-token
          key: token
# external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: demo-app-credentials
  namespace: external-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: demo-app-credentials
  data:
    - secretKey: password
      remoteRef:
        key: demo-app
        property: password
kubectl apply -f secret-store.yaml
kubectl apply -f external-secret.yaml

kubectl -n external-secrets get secret demo-app-credentials \
  -o jsonpath='{.data.password}' | base64 -d

Correct output:

hunter2-placeholder

The value travelled from Vault into a Kubernetes Secret without ever being written to the git repository holding secret-store.yaml and external-secret.yaml — those two files contain no credential at all, only references. The one value that never went through git is vault-token, created by the direct kubectl create secret command above, run once, outside the loop it makes possible.

Conclusion

The circularity was never a puzzle with a clever fix hiding in it — it’s a real boundary, and the only mistake available is refusing to admit where it is.

Every automated system has at least one credential that has to be provisioned before the automation exists. Naming that credential explicitly, and being honest that it sits outside GitOps, is safer than pretending the whole chain is self-bootstrapping when one link in it quietly isn’t.

Narrow the manual step to the smallest thing that has to be manual. Here, that’s one Vault token in one namespace — not the operator’s deployment, not any application’s credentials, not the SecretStore configuration. Everything that can go through GitOps should; the exception should be small enough to document in one paragraph.

An out-of-band step needs an out-of-band rotation plan, or it becomes the thing nobody remembers how to change. A credential that bypassed GitOps to get in bypasses it again every time it needs to be replaced — write that runbook at the same time as the bootstrap step, not after the token has already expired in production. </content>