Kubernetes Secrets: ESO vs CSI vs Init Containers

Table of Contents
Kubernetes Secrets: ESO vs CSI vs Init Containers
Introduction
It’s 3 AM and Vault is down. Your on-call engineer gets paged because deployments are failing — pods stuck in ContainerCreating, blocking a critical hotfix. Meanwhile, another team’s services keep humming along despite the same outage. The difference isn’t luck. It’s how secrets get into pods.
Kubernetes secrets have a fundamental problem: they’re base64-encoded, not encrypted. They sit in etcd alongside your cluster state, readable by anyone with RBAC access to the namespace. External secret managers like Vault, AWS Secrets Manager, and Azure Key Vault solve the security problem by keeping secrets outside the cluster — but they create a new problem. Your pods now depend on an external service, and that dependency has failure modes you need to understand before the 3 AM page.
Three patterns dominate secret injection:
- External Secrets OperatorSyncs secrets on a schedule and caches them as native Kubernetes secrets.
- Secrets Store CSI DriverFetches secrets on-demand when pods start.
- Init containersGives you complete control but require you to implement everything yourself.
Each pattern fails differently when the secret manager goes away. Consider a 30-minute Vault outage during a leadership election:
| Pattern | Existing Pods | New Pods | Recovery |
|---|---|---|---|
| ESO | ✅ Running | ✅ Start (cached) | Automatic |
| CSI Driver | ✅ Running | ❌ Blocked | May need intervention |
| Init Container | ✅ Running | ⚠️ Depends | Custom logic |
ESO‘s pods keep running with their last-synced secrets, and new pods start successfully because the Kubernetes Secret already exists — it’s just stale. CSI Driver pods that are already running continue fine, but any new pods can’t fetch their secrets and get stuck. Init containers? That depends entirely on what retry and fallback logic you built.

Three secret injection patterns.description
Flowchart comparing three ways Kubernetes workloads receive secrets from an external secret manager. At the top, an external group labeled External Secret Manager contains Vault, AWS Secrets Manager, or another provider. Inside a grouped Kubernetes Cluster section, the first pattern labeled Pattern 1: ESO shows ESO Controller writing to a Kubernetes Secret, which is then consumed by a pod through envFrom or volumeMount. The second pattern labeled Pattern 2: CSI Driver shows the CSI Driver DaemonSet providing a pod volume mount. The third pattern labeled Pattern 3: Init Container shows an Init Container writing secret data into a shared emptyDir, which the Main Container then reads. The external manager connects to ESO through a path labeled Periodic sync and connects to both the CSI Driver and Init Container through paths labeled On pod start. The diagram emphasizes that ESO caches into native Kubernetes Secrets, CSI fetches during pod startup, and init containers implement a custom startup-time retrieval path.
This article uses HashiCorp Vault for examples, but the patterns apply equally to AWS Secrets Manager, Azure Key Vault, or any other provider. The injection mechanisms don’t care where secrets come from — only how they get into pods.
Native Kubernetes Secrets
Before diving into external secret managers, let’s establish what native Kubernetes secrets actually provide. A Secret is a Kubernetes object that stores sensitive data — passwords, tokens, keys — separate from pod specs and container images. Pods consume secrets either as environment variables or as files mounted from a volume:
apiVersion: v1
kind: Secret
metadata:
name: database-credentials
namespace: production
type: Opaque
data:
username: YWRtaW4= # base64("admin")
password: c3VwZXJzZWNyZXQ= # base64("supersecret")
---
apiVersion: v1
kind: Pod
metadata:
name: app-with-secrets
spec:
containers:
- name: app
image: myapp:latest
env:
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: database-credentials
key: username
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: true
volumes:
- name: secrets
secret:
secretName: database-credentialsWhen mounted as a volume, each key in the Secret becomes a separate file. In this example, the pod gets /etc/secrets/username and /etc/secrets/password—plain text files containing the decoded secret values. Your application reads these files directly.
By default, secret files are mounted with 0644 permissions (owner read/write, group and world read). For sensitive credentials, tighten this with defaultMode in the volume spec: defaultMode: 0400 restricts access to owner-read-only. Note that some applications may fail if they can’t read their own config files — test permission changes before deploying.
The critical thing to understand: base64 is encoding, not encryption. Anyone with RBAC access to read secrets in a namespace can decode them trivially. By default, secrets are stored unencrypted in etcd — your cluster’s backing store. An etcd backup contains every secret in plaintext.
You can enable encryption at rest using an EncryptionConfiguration that tells the API server to encrypt secrets before writing to etcd:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- kms:
name: aws-kms
endpoint: unix:///var/run/kmsplugin/socket.sock
cachesize: 1000
timeout: 3s
- identity: {} # Fallback for reading old unencrypted secretsEven with encryption at rest, native secrets have operational limitations. There’s no rotation mechanism — you update secrets manually and redeploy pods. There’s no versioning — if you overwrite a secret, the old value is gone. And while Kubernetes audit logs can show who accessed the Secret object, they can’t tell you who accessed the actual secret value inside a running container. These gaps are why external secret managers exist.
External Secrets Operator (ESO)
External Secrets Operator is a Kubernetes operator that syncs secrets from external managers — Vault, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager — into native Kubernetes secrets. The operator runs in your cluster, periodically fetches secrets from the external source, and creates or updates Kubernetes Secret objects that your pods consume normally.
| Aspect | Native Secrets | External Manager |
|---|---|---|
| Encryption at rest | Must configure | Built-in |
| Access audit | API-level only | Comprehensive |
| Rotation | Manual | Automatic |
| Versioning | None | Full history |
| Cross-cluster | Manual sync | Centralized |
ESO uses three custom resources. A ClusterSecretStore (or namespace-scoped SecretStore) defines how to connect to the external secret manager — endpoint, authentication method, default paths. An ExternalSecret declares which secrets to sync from that store and how to map them into a Kubernetes Secret. The operator watches ExternalSecret resources and reconciles them on a configurable interval.
CSI Architecture and Setup
A ClusterSecretStore for AWS Secrets Manager using IAM Roles for Service Accounts:
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: aws-secrets-manager
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: external-secrets-sa
namespace: external-secretsFor Vault, the store configures Kubernetes authentication:
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault
spec:
provider:
vault:
server: "https://vault.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
serviceAccountRef:
name: external-secrets-sa
namespace: external-secretsAzure Key Vault follows the same pattern, using workload identity for authentication:
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: azure-keyvault
spec:
provider:
azurekv:
tenantId: "your-tenant-id"
vaultUrl: "https://your-vault.vault.azure.net"
authType: WorkloadIdentity
serviceAccountRef:
name: external-secrets-sa
namespace: external-secretsExternalSecret Configuration
With a ClusterSecretStore configured, you create ExternalSecret resources that specify which secrets to sync. The simplest form maps individual secret properties to Kubernetes Secret keys:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: database-credentials
namespace: production
spec:
refreshInterval: 15m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: database-credentials
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: production/database
property: username
- secretKey: password
remoteRef:
key: production/database
property: passwordThe refreshInterval controls how often ESO checks the external source for changes. Shorter intervals mean fresher secrets but more API calls to your secret manager. For most workloads, 15-30 minutes balances freshness against API costs.
For secrets where you need to construct values from multiple sources — connection strings, for example — ESO supports templating:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: database-url
namespace: production
spec:
refreshInterval: 15m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: database-url
template:
data:
DATABASE_URL: |
postgresql://{{ .username }}:{{ .password }}@{{ .host }}:5432/{{ .database }}
data:
- secretKey: username
remoteRef:
key: production/database
property: username
- secretKey: password
remoteRef:
key: production/database
property: password
- secretKey: host
remoteRef:
key: production/database
property: host
- secretKey: database
remoteRef:
key: production/database
property: databaseThe key insight with ESO is the decoupling between secret fetching and pod lifecycle. The controller runs its reconciliation loop independently — watching ExternalSecrets, fetching from the external manager, updating Kubernetes Secrets. Pods consume those Secrets normally, unaware that they came from Vault or AWS. This decoupling is what gives ESO its graceful degradation: the Kubernetes Secret exists even when the external manager doesn’t respond.

ESO sync flow.description
Sequence diagram showing how External Secrets Operator synchronizes secret values into Kubernetes. The participants are ExternalSecret, ESO Controller, Secret Manager, Kubernetes Secret, and Pod. The flow begins when the ESO Controller watches an ExternalSecret resource. Inside a loop that repeats every refreshInterval, the controller fetches secret values from the external Secret Manager, receives the values in response, and then creates or updates a Kubernetes Secret. Separately, a Pod mounts that Kubernetes Secret and receives the secret data. A note over the ESO Controller and Secret Manager explains that if the Secret Manager is down, the Kubernetes Secret keeps its last value. The diagram highlights that ESO fetches asynchronously on a controller schedule and that pods consume cached Kubernetes Secret data rather than making direct startup-time calls to the external manager.
ESO Failure Modes
ESO‘s failure modes are relatively benign because it decouples secret fetching from pod lifecycle. When the secret manager goes down, the ESO controller logs errors and keeps retrying — but the Kubernetes Secret it already created remains unchanged. Existing pods keep running with their last-synced values. Crucially, new pods can also start: they mount the Kubernetes Secret normally, unaware that ESO is failing to sync. The Kubernetes Secret itself is the cache — it persists in etcd independent of the external manager’s availability.
The cached Secret is encrypted in etcd only if you’ve configured encryption at rest. The external manager’s encryption doesn’t carry over — once ESO syncs a secret into Kubernetes, it’s subject to your cluster’s encryption configuration.
The downside: silent staleness. If you rotate a database password in Vault but ESO can’t sync for two hours, your pods run with the old password. They work fine until something restarts them with the now-invalid cached secret. This is why monitoring sync status matters:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: eso-alerts
spec:
groups:
- name: eso.rules
rules:
- alert: ExternalSecretSyncFailed
expr: |
externalsecret_status_condition{condition="Ready", status="False"} == 1
for: 15m
labels:
severity: warning
annotations:
summary: "ExternalSecret {{ $labels.name }} sync failing"
- alert: ExternalSecretStale
expr: |
time() - externalsecret_reconcile_timestamp_seconds > 7200
labels:
severity: warning
annotations:
summary: "ExternalSecret {{ $labels.name }} not synced in 2+ hours"You can check sync status directly with kubectl:
kubectl get externalsecret -A
# Look for Ready condition in STATUS columnESO‘s biggest advantage: graceful degradation. If Vault goes down, your running pods keep working with cached secrets. You might run with stale secrets until the next successful sync, but for most applications, that beats not running at all. For most applications, this is the right failure mode.
Secrets Store CSI Driver
The Secrets Store CSI Driver takes a fundamentally different approach than ESO. Instead of syncing secrets to Kubernetes Secret objects, it mounts secrets directly into pods as volumes. When a pod starts, the CSI driver fetches secrets from the external manager and presents them as files in the container’s filesystem — no Kubernetes Secret involved (unless you explicitly configure one).
The driver runs as a DaemonSet on every node, paired with a provider plugin for your secret manager (Vault, AWS, Azure, GCP). A SecretProviderClass resource defines which secrets to fetch and how to present them.
Architecture and Setup
A SecretProviderClass for Vault specifies the Vault address, authentication role, and which secrets to mount:
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: vault-secrets
namespace: production
spec:
provider: vault
parameters:
vaultAddress: "https://vault.example.com"
roleName: "production-app"
objects: |
- objectName: "database-username"
secretPath: "secret/data/production/database"
secretKey: "username"
- objectName: "database-password"
secretPath: "secret/data/production/database"
secretKey: "password"For AWS Secrets Manager, the configuration uses IRSA for authentication:
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: aws-secrets
namespace: production
spec:
provider: aws
parameters:
objects: |
- objectName: "production/database"
objectType: "secretsmanager"
jmesPath:
- path: username
objectAlias: db-username
- path: password
objectAlias: db-passwordPod Configuration
Pods reference the SecretProviderClass through a CSI volume. The driver authenticates using the pod’s service account, fetches the secrets, and mounts them as files:
apiVersion: v1
kind: Pod
metadata:
name: app-with-csi-secrets
namespace: production
spec:
serviceAccountName: production-app
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: secrets
mountPath: /mnt/secrets
readOnly: true
volumes:
- name: secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: vault-secretsThe pod cannot start until the volume mounts successfully — which means until secrets are fetched from Vault. This is the key difference from ESO: the dependency is synchronous.
The CSI driver optionally supports rotation. Enable it by setting --enable-secret-rotation=true on the driver DaemonSet and adding rotationPollInterval to your SecretProviderClass. When enabled, the driver periodically re-fetches secrets and updates the mounted files. Your application must detect file changes (using inotify or periodic re-reads) to pick up rotated values — the driver updates files, but it can’t restart your process.
The flow differs fundamentally from ESO: secrets are fetched synchronously during pod startup, not asynchronously by a controller.

CSI driver secret fetch flow.description
Sequence diagram showing how the Secrets Store CSI Driver fetches secrets during pod startup. The participants are Pod, CSI Driver, Vault Provider, and HashiCorp Vault. The flow begins when the Pod sends a mount volume request to the CSI Driver. The CSI Driver asks the Vault Provider to fetch secrets. The provider authenticates to Vault and reads the requested values. Vault returns the secret values to the provider, the provider returns secret data to the CSI Driver, and the CSI Driver mounts the secrets as files into the Pod. A note explains that the pod starts only after the secrets are mounted. An alternate failure path shows the provider attempting to authenticate to Vault when Vault is unavailable. Vault returns a connection failure, the provider returns an error to the CSI Driver, the CSI Driver reports that the mount fails, and a note states that the Pod stays Pending. The diagram emphasizes that CSI secret retrieval is synchronous and directly coupled to pod startup.
CSI Failure Modes
The CSI driver’s failure mode is the opposite of ESO‘s: loud and blocking. If the secret manager is unavailable when a pod starts, the volume mount fails. The pod stays in ContainerCreating with events showing MountVolume.SetUp failed. No graceful degradation — no start.
This has cascading implications. A brief Vault outage during a deployment blocks the entire rollout. A node reboot during an incident restarts pods that can’t fetch their secrets. Horizontal pod autoscaler scales up pods that immediately get stuck.
Diagnosing CSI failures:
# Check pod events for mount errors
kubectl describe pod <pod-name>
# Check CSI driver pods are healthy
kubectl get pods -n kube-system -l app=secrets-store-csi-driver
# Check provider logs for auth or connectivity issues
kubectl logs -n kube-system -l app=secrets-store-csi-driver-provider-vaultYou can optionally configure the SecretProviderClass to also sync secrets to a Kubernetes Secret (via secretObjects), giving you a fallback if needed — but at that point you’re combining the complexity of CSI with the staleness risk of cached secrets.
CSI driver’s failure mode is loud: if Vault is down, pods don’t start. This can cascade — a brief Vault outage during a deployment blocks the entire rollout. Plan for this with pre-deployment health checks or cached fallbacks.
Init Container Pattern
The init container pattern is the DIY approach: you write shell scripts that fetch secrets before your main application starts. No operators, no CRDs — just an init container that runs the Vault CLI or AWS CLI, writes secrets to a shared volume, then exits. The main container mounts that volume and reads the files.
This gives you complete control over failure handling. You decide the retry count, backoff strategy, fallback sources, and logging. The cost is maintaining that logic yourself.
Basic Implementation
The pattern uses an emptyDir volume with medium: Memory (tmpfs) shared between the init container and main container. The init container authenticates to Vault using the pod’s service account, fetches secrets, and writes them as files:
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-with-init-secrets
namespace: production
spec:
template:
spec:
serviceAccountName: production-app
volumes:
- name: secrets
emptyDir:
medium: Memory
initContainers:
- name: fetch-secrets
image: vault:latest
env:
- name: VAULT_ADDR
value: "https://vault.example.com"
command:
- /bin/sh
- -c
- |
set -e
VAULT_TOKEN=$(vault write -field=token auth/kubernetes/login \
role=production-app \
jwt=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token))
export VAULT_TOKEN
MAX_RETRIES=5
RETRY_DELAY=5
for i in $(seq 1 $MAX_RETRIES); do
if vault kv get -format=json secret/production/database > /secrets/database.json; then
jq -r '.data.data.username' /secrets/database.json > /secrets/db-username
jq -r '.data.data.password' /secrets/database.json > /secrets/db-password
rm /secrets/database.json
exit 0
fi
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
echo "Failed to fetch secrets after $MAX_RETRIES attempts"
exit 1
volumeMounts:
- name: secrets
mountPath: /secrets
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: trueThe main container reads /etc/secrets/db-username and /etc/secrets/db-password as plain text files. Using medium: Memory keeps secrets in tmpfs rather than writing them to disk — important for compliance.
Fallback Pattern
For resilience during secret manager outages, you can combine the init container with a fallback Kubernetes Secret. Try the external source first; fall back to cached credentials if unavailable:
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-with-fallback
spec:
template:
spec:
volumes:
- name: secrets
emptyDir:
medium: Memory
- name: fallback-secrets
secret:
secretName: database-credentials-fallback
optional: true
initContainers:
- name: fetch-secrets
image: amazon/aws-cli:latest
command:
- /bin/sh
- -c
- |
set -e
if aws secretsmanager get-secret-value \
--secret-id production/database \
--query SecretString \
--output text > /secrets/credentials.json 2>/dev/null; then
jq -r '.username' /secrets/credentials.json > /secrets/db-username
jq -r '.password' /secrets/credentials.json > /secrets/db-password
rm /secrets/credentials.json
elif [ -f /fallback/username ]; then
echo "Using fallback secrets"
cp /fallback/username /secrets/db-username
cp /fallback/password /secrets/db-password
else
echo "No secrets available!"
exit 1
fi
volumeMounts:
- name: secrets
mountPath: /secrets
- name: fallback-secrets
mountPath: /fallback
readOnly: true
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: trueThe fallback Secret could be synced by ESO or maintained manually. This gives you CSI-like freshness with ESO-like resilience — at the cost of maintaining the logic yourself.
Sidecar Refresh Pattern
Init containers only run at pod startup. For long-running pods that need rotated secrets, add a sidecar container that periodically refreshes the files:
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: true
- name: secret-refresher
image: vault:latest
command:
- /bin/sh
- -c
- |
while true; do
sleep 300
VAULT_TOKEN=$(vault write -field=token auth/kubernetes/login \
role=production-app \
jwt=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token))
export VAULT_TOKEN
vault kv get -field=username secret/production/database > /secrets/db-username.new
vault kv get -field=password secret/production/database > /secrets/db-password.new
mv /secrets/db-username.new /secrets/db-username
mv /secrets/db-password.new /secrets/db-password
done
volumeMounts:
- name: secrets
mountPath: /secretsThe atomic mv ensures your application sees either the old or new file, never a partial write. Your application still needs to detect file changes (inotify or periodic re-read) to pick up rotated values.
This sidecar pattern works with any file-based secret mounting — including CSI driver volumes. If CSI‘s built-in rotation doesn’t meet your needs, you can layer a sidecar on top for custom refresh logic.
The init container pattern makes sense when you need behavior that ESO and CSI don’t provide: custom retry logic, multiple fallback sources, complex secret transformations, or integration with legacy systems. For standard use cases, the operational overhead usually isn’t worth it.
Comparison and Selection
With three patterns to choose from, the decision comes down to two questions: how fresh do your secrets need to be, and what failure mode can your application tolerate?
| # | Criteria | ESO | CSI Driver | Init Container | Native |
|---|---|---|---|---|---|
| 1 | Sync model | Periodic | On pod start | On pod start | Manual |
| 2 | Secret freshness | Minutes | At pod start | At pod start | Static |
| 3 | Failure mode | Silent (stale) | Loud (blocked) | Configurable | None |
| 4 | Pod startup impact | None | Adds latency | Adds latency | None |
| 5 | K8s Secret created | Yes | Optional | No | Yes |
| 6 | GitOps friendly | Yes | Partial | No | Yes |
| 7 | Complexity | Medium | Medium | High | Low |
The table captures raw capabilities, but choosing a pattern requires weighting those capabilities against your operational reality. A team with strong GitOps practices and tolerance for minutes of staleness will land in a different place than a team running payment infrastructure where a stale database credential means failed transactions. The deciding factor is usually the failure mode: ESO fails silently with stale data, CSI fails loudly by blocking pod startup, and init containers fail however you code them to. Each is the right answer for different risk profiles.
- ESOFits most workloads. It's the simplest operationally: install the operator, create ExternalSecrets, and secrets flow into your cluster. GitOps works naturally because ExternalSecrets are declarative resources. You accept potential staleness — your secrets are only as fresh as your `refreshInterval`. For web applications, APIs, and most microservices, minutes of staleness is fine.
- CSI DriverFits workloads that need fresh secrets at startup and can tolerate blocking failures. Payment processing, credential rotation during incident response, or compliance requirements that prohibit caching secrets in Kubernetes — these are CSI use cases. You accept brittleness: when Vault is down, pods don't start.
- Init containersFits edge cases where ESO and CSI don't provide enough control. Legacy applications that need secrets in unusual formats, multi-source fallback logic, or integration with systems that don't have ESO/CSI providers. You're writing and maintaining the logic yourself.
- Native secretsFits non-sensitive configuration and development environments. With encryption at rest configured, they're acceptable for data that doesn't require rotation or audit trails.

Pattern decision tree.description
Flowchart guiding the choice between Kubernetes secret injection patterns based on failure tolerance. The process starts at Choose Secret Pattern and asks whether the application can tolerate minutes of staleness. If yes, the recommendation is ESO. If no, the chart asks whether the team accepts pod failures when the secret manager is down. If yes, the recommendation is CSI Driver. If no, the chart asks whether custom fallback logic is needed. If yes, the recommendation is Init Container. If no, the recommendation is ESO plus a short interval. The diagram highlights that the main decision points are tolerance for stale secrets, tolerance for blocked pod startup, and willingness to build custom fallback behavior.
Recommendations by Use Case
- Web applications and APIsESO with 15-minute refresh. These workloads tolerate brief staleness, and ESO's graceful degradation keeps them running through secret manager outages.
- Database connectionsESO with 5-minute refresh. Connection pools cache connections anyway, so the staleness window compounds. Shorter refresh reduces the risk of rotated credentials breaking pooled connections.
- Payment and financial processingCSI driver without `secretObjects`. Fresh secrets at startup, no caching in Kubernetes, clear audit trail. Accept that deployments block during Vault outages — for payment systems, that's preferable to using stale credentials.
- Multi-cluster deploymentsESO with different ClusterSecretStores per cluster. Consistent pattern across environments, centralized secrets in Vault or cloud provider.
- Legacy applicationsInit container with fallback. When you need secrets in unusual formats or custom retry logic, you control the implementation.
Most organizations should standardize on ESO for the majority of workloads, with CSI driver reserved for specific high-security applications. This gives you operational simplicity (one pattern to understand and monitor) with escape hatches for edge cases.
Production Considerations
Whichever pattern you choose, getting secrets into pods is only half the problem. Keeping them secure, monitoring for failures, and responding to incidents requires additional configuration.
Security Hardening
Whichever pattern you choose, the security fundamentals remain the same: authenticate with short-lived credentials, scope access narrowly, and restrict network paths.
- 1Vault configurationUse Kubernetes authentication rather than static tokens — the pod's service account becomes its identity. Scope Vault policies to specific paths: an application that reads `secret/production/database` shouldn't have access to `secret/production/payment`. Enable audit logging so you can trace who accessed what.
- 2Kubernetes configurationKubernetes native RBAC should be used to limit who can read Secrets in each namespace. Use network policies to restrict which pods can reach your secret manager — the ESO controller needs access, but your application pods don't need direct Vault connectivity.
- 3Cloud provider configurationUse IAM roles scoped to specific secrets, not broad permissions. Route traffic through VPC endpoints rather than the public internet. Enable CloudTrail or equivalent audit logging.
A hardened ESO setup uses internal-only endpoints, mTLS, and scoped Vault roles:
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-hardened
spec:
provider:
vault:
server: "https://vault.internal.example.com"
path: "secret"
version: "v2"
namespace: "production"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets-production"
serviceAccountRef:
name: external-secrets-sa
namespace: external-secrets
caProvider:
type: Secret
name: vault-ca
namespace: external-secrets
key: ca.crtNetwork policies restrict egress from the ESO namespace to only the Vault endpoint:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-vault-access
namespace: external-secrets
spec:
podSelector:
matchLabels:
app: external-secrets
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 10.0.0.0/8
ports:
- protocol: TCP
port: 8200Depending on your cluster’s DNS configuration, you may also need to allow egress to kube-dns (typically UDP port 53 to the kube-system namespace) for DNS resolution to work.
Monitoring and Alerting
Monitor three things: the injection mechanism (ESO sync status, CSI mount success), the secret manager (Vault health, latency), and the end-to-end path (for example, whether a test secret can actually be fetched).
For ESO, the key metric is sync status. An ExternalSecret that hasn’t synced in multiple refresh intervals indicates a problem:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: secret-injection-alerts
spec:
groups:
- name: secrets.rules
rules:
- alert: ExternalSecretSyncFailed
expr: externalsecret_status_condition{condition="Ready",status="False"} == 1
for: 15m
labels:
severity: warning
annotations:
summary: "ExternalSecret {{ $labels.name }} not syncing"
- alert: SecretCSIMountFailed
expr: |
increase(kubelet_csi_operations_seconds_count{
driver_name="secrets-store.csi.k8s.io",
method_name="NodePublishVolume",
grpc_status_code!="OK"
}[5m]) > 0
labels:
severity: critical
annotations:
summary: "CSI secret mount failures on {{ $labels.node }}"
- alert: VaultUnreachable
expr: vault_up == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Vault unreachable from cluster"| # | Metric | Warning | Critical | Response |
|---|---|---|---|---|
| 1 | ESO sync age | > 2× refreshInterval | > 4× refreshInterval | Check connectivity |
| 2 | CSI mount failures | > 1/hour | > 5/hour | Check provider pods |
| 3 | Vault latency | > 500ms | > 2s | Scale Vault, check network |
| 4 | Auth failures | Any | > 5/hour | Check IAM/Vault policies |
ESO sync success doesn’t mean Vault is healthy — it means the last sync worked. Add end-to-end health checks that actually fetch a test secret. A synthetic ExternalSecret that syncs every minute gives you real-time visibility into the secret path.
Conclusion
Secret injection is ultimately about failure modes. ESO fails silently — your pods keep running with stale secrets until you notice sync errors in monitoring. CSI driver fails loudly — pods don’t start, deployments block, and you know immediately something is wrong. Init containers fail however you code them.
For most organizations, ESO is the right default. It’s operationally simple, GitOps-friendly, and its failure mode (staleness) is tolerable for the vast majority of workloads. Reserve CSI driver for applications with strict compliance requirements or real-time credential needs. Use init containers only when you need behavior that neither operator provides.
Start simple. ESO with a 15-minute refresh, encryption at rest enabled, and proper Vault policies handles most production workloads. Add complexity only when you have a specific requirement that demands it.
The 3 AM Vault outage that opened this article? The team running ESO stayed asleep — their pods kept serving traffic with cached secrets. The team running CSI driver got paged when deployments failed. Both outcomes are defensible, depending on what you’re building. The mistake is not understanding which failure mode you’ve chosen.
Sharethisarticle
Enjoyed the read? Share it with your network.




