Back to insights

External secrets operator for Kubernetes: HashiCorp Vault GitOps setup

Learn how to integrate External Secrets Operator with HashiCorp Vault in Kubernetes. Complete GitOps setup with FluxCD and working configurations.

This is part 3 of our HashiCorp Vault production series. In part 1, we explained why we run Vault in a separate cluster. Now comes the next challenge: how do you get secrets from Vault to your applications without storing them in Git? Series overview:

What is External Secrets Operator for Vault Integration?

External Secrets Operator (ESO) is a Kubernetes operator that synchronizes secrets from external systems like HashiCorp Vault into Kubernetes secrets. It solves a fundamental GitOps problem: keeping sensitive data out of Git while maintaining declarative configuration. In our setup, ESO:

The GitOps Secret Management Problem in Kubernetes

In a GitOps workflow, everything should be in Git. But putting secrets in Git, even encrypted, creates risks:

ESO solves this by storing only the reference to secrets in Git, not the secrets themselves.

Implementing External Secrets Operator with HashiCorp Vault

Here's how we configured External Secrets Operator to work with our Vault setup.

Prerequisites

From our architecture:

Installing external secrets operator

ESO is deployed via FluxCD in our application cluster:

apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: external-secrets
namespace: external-secrets-system
spec:
interval: 1h
url: https://charts.external-secrets.io

---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
name: external-secrets
namespace: external-secrets-system
spec:
interval: 5m
chart:
spec:
chart: external-secrets
sourceRef:
kind: HelmRepository
name: external-secrets

Configuring ClusterSecretStore for Vault

The ClusterSecretStore tells ESO how to connect to Vault:

apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "https://vault.tooling.internal:8200"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
serviceAccountRef:
name: "external-secrets"
namespace: "external-secrets-system"

This configuration:

Creating external secrets

With the ClusterSecretStore configured, we can create ExternalSecret resources:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
namespace: production
spec:
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
refreshInterval: 15m
target:
name: app-secrets
creationPolicy: Owner
data:
- secretKey: database-password
remoteRef:
key: secret/data/production/database
property: password
- secretKey: api-key
remoteRef:
key: secret/data/production/external-api
property: key

ESO will:

FluxCD and HashiCorp Vault integration for complete GitOps

The real power comes from combining ESO with FluxCD's substitution feature. This allows us to use secrets in our GitOps workflow without exposing them.

Secret substitution in FluxCD with external secrets

FluxCD can substitute variables in your manifests using data from secrets:

apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
name: app-deployment
namespace: flux-system
spec:
interval: 10m
path: "./apps/production"
prune: true
sourceRef:
kind: GitRepository
name: flux-system
postBuild:
substituteFrom:
- kind: Secret
name: app-secrets
optional: false

Using substituted values

In your application manifests, use placeholders:

apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
database-url: "postgres://app:${database-password}@db:5432/app"
external-api-endpoint: "https://api.example.com"
external-api-key: "${api-key}"

FluxCD will replace ${database-password} and ${api-key} with values from the ESO-generated secret.

Production patterns with external secrets operator and HashiCorp Vault

Namespace isolation

We use SecretStore (namespace-scoped) instead of ClusterSecretStore for application-specific secrets:

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: team-a-vault
namespace: team-a
spec:
provider:
vault:
server: "https://vault.tooling.internal:8200"
path: "secret/team-a"
# Rest of config similar to ClusterSecretStore

Secret rotation handling

While ESO doesn't support automatic rotation for dynamic secrets, the refresh interval ensures updates are pulled regularly. For dynamic credentials, we use Vault Agent (covered in part 4).

Monitoring secret synchronization

Check ESO sync status:

kubectl get externalsecrets -A

Look for the READY and LAST SYNC columns to verify secrets are syncing properly.

Troubleshooting common issues

ESO can't connect to Vault

Verify the ClusterSecretStore status:

kubectl describe clustersecretstore vault-backend

Common issues:

Secrets not updating

Check the ExternalSecret events:

kubectl describe externalsecret app-secrets -n production

The refresh interval might be too long, or there could be permission issues in Vault.

Production considerations

High Availability

ESO runs as a deployment with multiple replicas. Ensure your configuration includes:

replicaCount: 3
resources:
requests:
memory: 64Mi
cpu: 10m

Security

Performance

With hundreds of ExternalSecrets, consider:

What's Next?

We've covered how to sync static secrets from Vault to Kubernetes using ESO. But what about dynamic database credentials that expire? That's where Vault's database secret engine shines - covered in our next post. The complete configuration shown here is available in our GitHub repository, including:

Explore our DevOps tools

View on GitHub

Kilian Niemegeerts