Vault + External Secrets Operator
This guide covers deploying HashiCorp Vault as a central secret store and External Secrets Operator (ESO) to sync secrets into Kubernetes automatically.
Architecture
[Vault] namespace: vault
│ Kubernetes auth method (ServiceAccount token exchange)
▼
[ESO ClusterSecretStore] — cluster-wide, references Vault KV v2
│ ExternalSecret CRs per namespace
▼
[Kubernetes Secrets] — consumed natively by pods / Helm chartsStorage: Vault uses Raft integrated storage backed by a local-path PVC — no external Consul or etcd required.
Auth: ESO authenticates to Vault via the Kubernetes auth method. ESO's own ServiceAccount token is exchanged for a scoped Vault token at sync time.
UI access: Vault UI is behind the WireGuard vpn-only middleware. Bring up your WireGuard VPN connection before opening the browser.
Secret path layout
| Vault path | Kubernetes Secret | Namespace |
|---|---|---|
secret/argocd/oidc | argocd-secret | argocd |
secret/grafana/admin | grafana-admin-secret | monitoring |
secret/grafana/oauth | grafana-oauth-secret | monitoring |
secret/traefik/dashboard | traefik-dashboard-auth | ingress |
Prerequisites
Add to .env:
VAULT_DOMAIN=vault.example.comAdd DNS A record: vault.example.com → SERVER_IP
Step 1 — Deploy Vault
Deploy the platform-vault OCI chart via ArgoCD or directly with Helm:
helm upgrade --install platform-vault \
oci://ghcr.io/kevindebenedetti/charts/platform-vault \
--version 0.7.4 \
--namespace vault --create-namespace \
--values platform/vault/values.yamlThis installs Vault into the vault namespace with:
- Single-replica Raft storage (5 Gi PVC on
local-path) - UI enabled (ClusterIP, exposed via Traefik IngressRoute)
- VPN-only IngressRoute + Let's Encrypt TLS cert
⚠️ Vault starts sealed and uninitialized — proceed to Step 2.
Step 2 — Initialize Vault
task vault:initThis script:
- Calls
vault operator init(3 key shares, threshold 2) - Unseals Vault with the generated keys
- Enables KV v2 at
secret/ - Enables and configures Kubernetes auth
- Creates the
eso-readpolicy andesorole
Output:
════════════════════════════════════════════════════════════════
⚠️ VAULT INIT OUTPUT — SAVE THIS IMMEDIATELY
These keys and token will NEVER be shown again.
════════════════════════════════════════════════════════════════
Unseal Key 1: abc123...
Unseal Key 2: def456...
Unseal Key 3: ghi789...
Root Token: hvs.XXXXX📋 Store these in a password manager immediately. You will need 2 of the 3 unseal keys any time the Vault pod restarts (e.g., after a node reboot).
Add to .env for convenience (never commit):
VAULT_ROOT_TOKEN=hvs.XXXXX
VAULT_UNSEAL_KEY_1=abc123...
VAULT_UNSEAL_KEY_2=def456...Step 3 — Seed secrets into Vault
VAULT_ROOT_TOKEN=hvs.XXXXX task vault:seedThe interactive prompt walks you through storing each secret:
secret/argocd/oidc— Infomaniak OIDCclientID+clientSecretsecret/grafana/admin— Grafana adminusername+passwordsecret/grafana/oauth— AllGF_AUTH_*OAuth env varssecret/traefik/dashboard— Traefik BasicAuthpassword
Step 4 — Deploy External Secrets Operator
ArgoCD deploys External Secrets Operator from Git. No manual deploy command is needed.Installs the external-secrets/external-secrets Helm chart into the external-secrets namespace.
Step 5 — Apply ClusterSecretStore + ExternalSecrets
# In your infra repo:
kubectl apply -f secrets/cluster-secret-store.yaml
kubectl apply -f secrets/ESO immediately begins syncing. Check status:
task vault:statusDay 2 — Unsealing after reboot
Vault becomes sealed when the pod restarts. Unseal with:
task vault:unseal # requires VAULT_UNSEAL_KEY_1 + VAULT_UNSEAL_KEY_2 in .envOr interactively:
kubectl exec -it -n vault vault-0 -- env VAULT_SKIP_VERIFY=true vault operator unsealAdding new secrets
Write to Vault:
bashkubectl exec -n vault vault-0 -- \ env VAULT_TOKEN="${VAULT_TOKEN:?set-from-vault-init}" VAULT_SKIP_VERIFY=true \ vault kv put secret/myapp/config \ DB_PASSWORD=secret123 \ API_KEY=key456Create an ExternalSecret manifest:
yamlapiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: name: myapp-secret namespace: apps spec: refreshInterval: 1h secretStoreRef: name: vault kind: ClusterSecretStore target: name: myapp-secret creationPolicy: Owner data: - secretKey: DB_PASSWORD remoteRef: key: secret/myapp/config property: DB_PASSWORD - secretKey: API_KEY remoteRef: key: secret/myapp/config property: API_KEYApply:
kubectl apply -f myapp-externalsecret.yaml