External Secrets¶
Secrets never live in Git. The External Secrets Operator (ESO) reads values from GCP Secret Manager and AWS Secrets Manager and projects them into Kubernetes Secret objects that pods envFrom at runtime.
Installed by k8s/envs/platform/external-secrets/application.yaml. The cluster-scoped store definitions live in k8s/envs/platform/manifests/external-secrets-config.yaml and are deployed by the separate sys-resources Application.
Chart and source¶
| Field | Value |
|---|---|
| Helm chart | external-secrets |
| Repository | https://charts.external-secrets.io |
| Version | 0.10.3 |
| Destination namespace | external-secrets (created by Argo CD) |
| Argo CD Application | external-secrets |
The chart installs the ESO controller, the webhook, and the CRDs (ClusterSecretStore, SecretStore, ExternalSecret, PushSecret).
Secret stores¶
One cluster-wide store (gsm-tomoda, GCP) plus a namespace-scoped AWS store per tenant.
gsm-tomoda — GCP Secret Manager (cluster-wide)¶
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: gsm-tomoda
spec:
provider:
gcpsm:
projectID: development-485000
No auth block — ESO authenticates via Workload Identity using the ESO controller's KSA, which is bound to a GCP SA with roles/secretmanager.secretAccessor on the project. That binding is set up in Terraform alongside the cluster (see Infrastructure → GCP).
This store backs every application secret in the cluster — JWT secret, OAuth client secret, Stripe keys, email API keys, Postgres password, Redis password. The full inventory is in Secrets Management.
aws-sm-<env> — AWS Secrets Manager (per namespace)¶
Each tenant (tomoda-dev, tomoda-prod) owns a namespace-scoped SecretStore — defined in k8s/apps/tomoda/overlays/<env>/external-secret.yaml, not the cluster-wide manifest.
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: aws-sm-dev
namespace: tomoda-dev
spec:
provider:
aws:
service: SecretsManager
region: us-east-2
auth:
secretRef:
accessKeyIDSecretRef:
name: aws-eso-credentials-dev # same namespace
key: access-key
secretAccessKeySecretRef:
name: aws-eso-credentials-dev
key: secret-access-key
AWS has no Workload Identity equivalent for GKE pods, so this store needs a static IAM key (user tomoda-eso-reader-<env>, provisioned by AWS Terraform). But that key is not hand-created — it lives in GCP SM (tomoda-aws-eso-*-<env>) and is projected into the namespace by an ExternalSecret running against gsm-tomoda:
kind: ExternalSecret
metadata:
name: aws-eso-credentials-dev
namespace: tomoda-dev
spec:
secretStoreRef: { kind: ClusterSecretStore, name: gsm-tomoda }
target: { name: aws-eso-credentials-dev }
data:
- secretKey: access-key
remoteRef: { key: tomoda-aws-eso-access-key-dev }
- secretKey: secret-access-key
remoteRef: { key: tomoda-aws-eso-secret-key-dev }
So the whole chain bootstraps from Workload Identity — no kubectl create secret, everything GitOps. Per-namespace stores keep a key rotation or compromise scoped to one tenant. This store backs things only AWS can produce — currently the S3 uploader's IAM credentials. Full flow in Secrets Management.
Per-app ExternalSecret CRs¶
Once the stores exist, any namespace can pull values:
# k8s/envs/platform/manifests/external-secrets-config.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: google-oauth-secret
namespace: monitoring
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: gsm-tomoda
target:
name: google-oauth-credentials
creationPolicy: Owner
data:
- secretKey: client-secret
remoteRef:
key: oauth-client-secret
The result is a regular K8s Secret named google-oauth-credentials in the monitoring namespace, refreshed hourly from GCP SM. Grafana's Deployment then valueFrom: secretKeyRefs it as an environment variable.
The same pattern is used by the tomoda app's backend-secrets-{dev,prod} Secrets (under k8s/envs/{dev,prod}/tomoda/).
Refresh and rotation¶
- Refresh interval is per-ExternalSecret; the convention is
1h. ESO re-reads the upstream value and patches the K8s Secret if it changed. - Rotating a value upstream (in GCP SM or AWS SM) propagates into the cluster within the refresh window. Pods consuming the Secret via
envFromwill not pick up the new value until they restart — they only read env vars at process start. Forcing a rollout is the standard remedy:kubectl rollout restart deployment/<name>. - Force-refreshing an ExternalSecret immediately: annotate it with
force-syncor delete the target Secret (ESO recreates it on the next reconcile).
Operational notes¶
- Webhook dependency. The ESO controller's mutating webhook is required for
ExternalSecretadmission. If the controller is unhealthy, new ExternalSecrets cannot be created — existing K8s Secrets remain in place but stop refreshing. - The bootstrap secret is a single point of failure for AWS. If
aws-eso-credentialsis missing or its keys are wrong, every AWS-backed ExternalSecret fails. Existing K8s Secrets stay intact (creationPolicy: Owner means ESO won't delete on auth failure) but new ones can't be created. - No secret values are logged. ESO logs key names and store references only.
For the secret inventory, see Secrets Management.