When you run Vault inside Kubernetes, there’s a tempting shortcut: let ArgoCD or Flux manage the entire thing — Helm install, init, unseal, everything. The problem is that Vault initialization is imperative and stateful. It doesn’t fit in a GitOps manifest, and trying to force it there creates fragile workarounds.
The cleaner pattern is to treat self-hosted Vault the same way you’d treat AWS Secrets Manager or HashiCorp Cloud Vault: something that exists before the cluster, outside the cluster’s concern.
What “External Service” Means Here
The cluster doesn’t know or care how Vault got there. It only knows:
- There is a secret store at a known endpoint
- It is initialized and unsealed
- It has credentials pre-loaded
- It has an auth method configured for this cluster
ArgoCD, the External Secrets Operator, every workload — none of them participate in Vault’s lifecycle. They consume from it.
This is the same mental model as a managed secret service. You don’t ask AWS Secrets Manager to unseal itself. You don’t tell it to initialize. It’s just there.
Why GitOps Doesn’t Work for Vault Init
vault operator init is a one-time operation that generates unseal keys and a root token. Those outputs need to be stored somewhere before the next step can run. You can’t express that in a Helm chart values file or a Kubernetes manifest — there’s no concept of “run this command, capture the output, store it here, then proceed.”
Trying to put init in an ArgoCD hook or a Kubernetes Job creates awkward state management: where does the output go? How do you prevent it from running twice? How do you handle the Job failing halfway through?
Ansible handles this cleanly. It’s designed for imperative orchestration with stateful intermediate results.
The Split
Ansible (imperative, runs once from your machine):
- helm install vault
- vault operator init → captures unseal key + root token
- saves keys to .env and K8s Secret
- vault operator unseal
- vault secrets enable kv-v2
- vault auth enable kubernetes
- vault policy write / vault write auth/kubernetes/role
- vault kv put secret/... ← seeds all credentials
ArgoCD (declarative, runs forever from Git):
- owns the Vault Helm release for drift detection and upgrades
- owns ESO Helm release
- owns ClusterSecretStore
- owns all ExternalSecret manifests
Bootstrap does everything a managed service would handle invisibly. After that, the cluster sees a running Vault endpoint with auth configured and credentials loaded. It never touches the lifecycle.
The Handoff
ArgoCD taking ownership of the Vault Helm release doesn’t conflict with bootstrap having installed it first. When ArgoCD finds an existing Helm release with matching values, it adopts it. Subsequent Helm value changes go through Git — no manual helm upgrade needed after bootstrap.
The same handoff happens with ESO. Bootstrap doesn’t install it — that’s ArgoCD’s job. Bootstrap only configures Vault to trust ESO’s service account (the policy and Kubernetes auth role). ESO doesn’t exist yet when bootstrap runs, but Vault doesn’t care — the role is just a configuration entry waiting for ESO to show up and authenticate.
Seeding Credentials
Every credential the cluster needs goes into Vault during bootstrap:
vault kv put secret/gitea admin_user=admin admin_password=...
vault kv put secret/act-runner token=...
vault kv put secret/minio access_key=... secret_key=...
vault kv put secret/grafana admin_user=admin admin_password=...
These values come from .env (user-supplied) or are generated randomly during bootstrap (MinIO, Grafana). Once in Vault, ExternalSecret manifests in Git pull them into K8s Secrets. Nothing is hardcoded in Git.
Takeaway
Separate what needs to happen imperatively (Vault lifecycle, credential seeding) from what should be declarative (everything the cluster uses). Bootstrap is the stand-in for the operations a managed secret service handles for you. After bootstrap, the cluster interacts with Vault exactly as it would interact with an external managed service — by consuming from an endpoint that just exists.