The standard advice is “don’t put secrets in Git.” The harder question is what to do instead, especially when you need those secrets to reach running containers in a Kubernetes cluster that ArgoCD or Flux manages.
Here’s the full pipeline: from a local .env file through Vault KV v2 through External Secrets Operator to a K8s Secret that the application mounts. Nothing touches Git.
The Pipeline
.env (local, gitignored)
│
▼
bootstrap (Ansible, runs once)
│ vault kv put secret/myapp key=value
▼
Vault KV v2 (in-cluster, treated as external service)
│
▼
ClusterSecretStore (ArgoCD-managed, points ESO at Vault)
│
▼
ExternalSecret (in Git — references Vault paths, not values)
│
▼
K8s Secret (created by ESO, lives in cluster, never in Git)
│
▼
application (mounts Secret as env var or volume)
Git contains the ExternalSecret manifest. It says “read from secret/myapp, key key, write to K8s Secret named myapp-credentials.” It contains no values.
Vault Setup
Enable KV v2 and create a policy that allows ESO to read:
vault secrets enable -path=secret kv-v2
vault policy write external-secrets - <<EOF
path "secret/data/*" {
capabilities = ["read"]
}
path "secret/metadata/*" {
capabilities = ["read", "list"]
}
EOF
Enable Kubernetes auth so ESO can authenticate using its ServiceAccount token:
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host=https://kubernetes.default.svc:443
vault write auth/kubernetes/role/external-secrets \
bound_service_account_names=external-secrets \
bound_service_account_namespaces=external-secrets \
policies=external-secrets \
ttl=1h
ClusterSecretStore
The ClusterSecretStore tells ESO where Vault is and how to authenticate. It’s cluster-scoped so all namespaces can use it.
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "http://vault.vault.svc.cluster.local:8200"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
serviceAccountRef:
name: "external-secrets"
namespace: "external-secrets"
ExternalSecret
This lives in Git. It references Vault paths and property names — no values:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: myapp-credentials
namespace: myapp
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: myapp-credentials
creationPolicy: Owner
data:
- secretKey: api-key
remoteRef:
key: myapp
property: api_key
- secretKey: db-password
remoteRef:
key: myapp
property: db_password
ESO reconciles this every hour. If the Vault value changes, the K8s Secret is updated automatically.
Seeding Vault from .env
During bootstrap, read from .env and write to Vault:
vault kv put secret/myapp \
api_key="${API_KEY}" \
db_password="${DB_PASSWORD}"
For credentials that are generated (not user-supplied), generate them in bootstrap and write to both Vault and .env so they’re recoverable:
- name: Generate credentials
ansible.builtin.set_fact:
db_password: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters,digits') }}"
- name: Seed to Vault
ansible.builtin.shell: |
vault kv put secret/myapp db_password={{ db_password }}
- name: Save to .env
ansible.builtin.lineinfile:
path: .env
regexp: "^DB_PASSWORD="
line: "DB_PASSWORD={{ db_password }}"
On re-bootstrap (cluster rebuild), Vault is empty again — bootstrap re-seeds from .env.
What’s in Git, What Isn’t
| Resource | In Git | Contains values |
|---|---|---|
| ExternalSecret | yes | no — Vault path references only |
| ClusterSecretStore | yes | no — Vault URL + auth config |
| K8s Secret | no | yes — created by ESO at runtime |
| Vault KV entries | no | yes — lives in Vault |
.env |
no (gitignored) | yes — source of truth for bootstrap |
Takeaway
The ExternalSecret is the only secret-related thing in Git. It’s a pointer, not a value. The actual values live in Vault, seeded from .env by bootstrap. ESO bridges the two at runtime, creating K8s Secrets that applications consume normally. Rotating a credential means updating Vault — ESO propagates the change within refreshInterval.