No Secrets in Git: The Full Pipeline from .env to Kubernetes

Feb 27, 2026

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.