The External Secrets Operator (ESO) is a Kubernetes operator that synchronizes secrets from external secret management systems—such as Google Secret Manager, AWS Secrets Manager, HashiCorp Vault, or Azure Key Vault—into native Kubernetes Secrets. Instead of manually creating kubectl create secret entries or embedding secrets in Helm values, teams declare an ExternalSecret custom resource that references a secret in the external store. ESO’s controller watches these resources, fetches the secret values, and creates or updates the corresponding Kubernetes Secret automatically.
This pattern solves a fundamental tension in Kubernetes secret management: applications need secrets as Kubernetes Secret objects (mounted as volumes or environment variables), but storing secret values in Git, Helm charts, or CI/CD pipelines violates security best practices and SOC2 controls. ESO bridges this gap by keeping the secret source of truth in a purpose-built secret manager while delivering secrets to workloads in the format they expect.
In a typical production Kubernetes environment, teams deploy ESO via Helm and configure ClusterSecretStore resources that authenticate to Google Secret Manager using Workload Identity. Each environment (dev, staging, prod) has its own Secret Manager project, and ESO syncs database credentials, API keys, webhook secrets, and TLS certificates into the appropriate namespaces. This pattern helps satisfy SOC2 controls for secret rotation, access control, and audit logging of secret access.
A SecretStore is namespace-scoped—it can only be referenced by ExternalSecret resources in the same namespace. A ClusterSecretStore is cluster-scoped and can be referenced from any namespace. In practice, teams use ClusterSecretStore for the shared Google Secret Manager connection when secrets are consumed across multiple namespaces (search, pipeline, billing, etc.). This reduces configuration duplication while still allowing per-namespace access control through the ExternalSecret resources themselves.
An ExternalSecret declares which secrets to fetch, from which store, and how to map them into a Kubernetes Secret. Each ExternalSecret specifies a secretStoreRef (pointing to a ClusterSecretStore or SecretStore), a target (the name of the Kubernetes Secret to create), and a data array mapping remote secret keys to local secret keys. The controller reconciles this resource on a configurable interval (e.g., every 1 hour), automatically picking up rotated values from the external store.
When using Google Secret Manager with ESO, IAM conditions can restrict which secrets the ESO service account can access. Rather than granting roles/secretmanager.secretAccessor on the entire project, the infrastructure project uses conditional IAM bindings that scope access to secrets matching a resource name pattern (e.g., resource.name.startsWith("projects/PROJECT/secrets/prod-")). This prevents the prod ESO instance from reading dev secrets and vice versa, even though they share the same project.
ESO’s refreshInterval controls how often the controller re-fetches secrets from the external store. A short interval (e.g., 5 minutes) ensures rotated credentials are picked up quickly but increases API calls. A long interval (e.g., 24 hours) is cheaper but delays rotation propagation. A production setup uses 1-hour intervals for database credentials (which rotate quarterly) and 5-minute intervals for Stripe webhook signing secrets (which may be rotated during incident response).
ESO is deployed via its official Helm chart, which installs the CRDs (ExternalSecret, SecretStore, ClusterSecretStore, ClusterExternalSecret), the controller deployment, the webhook for validation, and the cert-controller for webhook TLS. Pulumi’s Helm release resource manages the installation, pinning to a specific chart version and passing values that configure the controller’s service account for Workload Identity authentication.
Setting up ESO for Google Secret Manager involves four steps: deploying the operator via Helm, creating a ClusterSecretStore that authenticates to Secret Manager, defining ExternalSecret resources for each secret, and verifying the sync status.
The infrastructure project manages the ESO Helm release as a Pulumi resource, ensuring the operator version is pinned and the service account is configured for Workload Identity. The Helm chart creates the CRDs, controller, webhook, and cert-controller in a dedicated namespace.
package k8s
import (
helmv3 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/helm/v3"
corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
func DeployESO(ctx *pulumi.Context, gsaEmail string) error {
ns, err := corev1.NewNamespace(ctx, "external-secrets", &corev1.NamespaceArgs{
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("external-secrets"),
},
})
if err != nil {
return err
}
_, err = helmv3.NewRelease(ctx, "external-secrets", &helmv3.ReleaseArgs{
Name: pulumi.String("external-secrets"),
Namespace: ns.Metadata.Name(),
Chart: pulumi.String("external-secrets"),
Version: pulumi.String("0.9.11"),
RepositoryOpts: &helmv3.RepositoryOptsArgs{
Repo: pulumi.String("https://charts.external-secrets.io"),
},
Values: pulumi.Map{
"serviceAccount": pulumi.Map{
"annotations": pulumi.Map{
"iam.gke.io/gcp-service-account": pulumi.String(gsaEmail),
},
},
"webhook": pulumi.Map{
"port": pulumi.Int(9443),
},
},
})
return err
}
The ClusterSecretStore tells ESO how to authenticate to Google Secret Manager. With Workload Identity, no explicit credentials are needed—ESO’s service account is bound to a GSA with Secret Manager access, and the GKE metadata server handles token exchange transparently.
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: gcp-secret-manager
spec:
provider:
gcpsm:
projectID: "mydb-prod"
auth:
workloadIdentity:
clusterLocation: us-central1
clusterName: prod-cluster
clusterProjectID: "mydb-prod"
serviceAccountRef:
name: external-secrets
namespace: external-secrets
Each secret that workloads need is declared as an ExternalSecret. The resource specifies which ClusterSecretStore to use, the refresh interval, and the mapping from Secret Manager secret names to Kubernetes Secret data keys.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: database-credentials
namespace: search
spec:
refreshInterval: 1h
secretStoreRef:
name: gcp-secret-manager
kind: ClusterSecretStore
target:
name: database-credentials
creationPolicy: Owner
data:
- secretKey: DB_HOST
remoteRef:
key: prod-db-host
- secretKey: DB_PASSWORD
remoteRef:
key: prod-db-password
- secretKey: DB_USERNAME
remoteRef:
key: prod-db-username
The infrastructure project creates ExternalSecret resources programmatically in Pulumi rather than applying raw YAML. This allows secrets to be parameterized by environment and namespace, with the secret key prefix (e.g., prod- vs dev-) derived from the stack configuration.
package k8s
import (
"fmt"
apiextv1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apiextensions"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
type SecretMapping struct {
LocalKey string // key in the Kubernetes Secret
RemoteKey string // key in Google Secret Manager
}
func NewExternalSecret(ctx *pulumi.Context, env, namespace, name string, mappings []SecretMapping) error {
data := make([]map[string]interface{}, len(mappings))
for i, m := range mappings {
data[i] = map[string]interface{}{
"secretKey": m.LocalKey,
"remoteRef": map[string]interface{}{
"key": fmt.Sprintf("%s-%s", env, m.RemoteKey),
},
}
}
_, err := apiextv1.NewCustomResource(ctx,
fmt.Sprintf("%s-%s-es", namespace, name),
&apiextv1.CustomResourceArgs{
ApiVersion: pulumi.String("external-secrets.io/v1beta1"),
Kind: pulumi.String("ExternalSecret"),
Metadata: pulumi.Map{
"name": pulumi.String(name),
"namespace": pulumi.String(namespace),
},
OtherFields: map[string]interface{}{
"spec": map[string]interface{}{
"refreshInterval": "1h",
"secretStoreRef": map[string]interface{}{
"name": "gcp-secret-manager",
"kind": "ClusterSecretStore",
},
"target": map[string]interface{}{
"name": name,
"creationPolicy": "Owner",
},
"data": data,
},
},
},
)
return err
}
The infrastructure project prefixes all Secret Manager secret names with the environment (e.g., prod-db-password, dev-db-password). The Pulumi stack configuration determines which prefix to use, so the same ExternalSecret template works across environments. This prevents accidental cross-environment secret access and makes IAM conditions straightforward: the prod ESO service account can only access secrets starting with prod-.
Setting creationPolicy: Owner on the target means the Kubernetes Secret is owned by the ExternalSecret resource. Deleting the ExternalSecret automatically garbage-collects the Kubernetes Secret, preventing orphaned secrets from accumulating. This is critical for namespace teardowns during environment reprovisioning.
ESO exposes the sync status on each ExternalSecret’s .status.conditions. A production setup includes a Prometheus alert rule that fires when any ExternalSecret has been in a SecretSyncedError state for more than 15 minutes, catching authentication failures, deleted upstream secrets, or API quota exhaustion before they impact workloads.
groups:
- name: external-secrets
rules:
- alert: ExternalSecretSyncFailed
expr: |
externalsecret_status_condition{condition="Ready", status="False"} == 1
for: 15m
labels:
severity: warning
annotations:
summary: "ExternalSecret {{ $labels.name }} in {{ $labels.namespace }} failed to sync"
description: "The ExternalSecret has been in error state for 15+ minutes. Check ESO controller logs and Secret Manager permissions."
ESO is well-suited for deployment across a Kubernetes infrastructure for secret management. For example:
search, pipeline, and billing namespaces. When database credentials are rotated, updating the Secret Manager value triggers automatic propagation within the refresh interval.ExternalSecret resources and short refresh intervals, ESO can exhaust the Secret Manager API quota (1,800 requests/minute by default). Monitor quota usage and increase the refresh interval for non-critical secrets.SecretStore in one namespace and trying to reference it from another namespace fails silently. Default to ClusterSecretStore unless you need namespace-level authentication isolation.