External Secrets
Kubernetes operator that syncs secrets from external secret stores into Kubernetes Secrets.
External Secrets Operator reads values from external secret management systems (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, and others) and writes them as Kubernetes Secret objects. In this cluster it does not talk to an external vault at all. SOPS stays the only source of secrets; External Secrets is the distribution mechanism that copies a handful of shared ones from a hub namespace into every namespace that asks for them.
Alternatives considered
Self Hosted
| Tool | Open Source | Full Features | Notes |
|---|---|---|---|
| Sealed Secrets | Yes | Yes | Encrypts secrets in Git; no external store needed |
| Vault Agent Injector | Yes | Yes | Requires HashiCorp Vault; sidecar injection model |
| SOPS + Flux | Yes | Yes | Git-native encryption; used alongside ESO in this cluster |
Installation
Architecture
HelmRelease external-secrets in namespace external-secrets, consuming the chart through chartRef from an OCIRepository at oci://ghcr.io/external-secrets/charts/external-secrets; this chart ships a .prov provenance layer ahead of the chart layer, which is why the source selects the chart media type explicitly. CRDs installed via installCRDs: true. Installs the operator (controller), webhook, and cert-controller components. Grafana dashboard enabled. Installs ExternalSecret, SecretStore, ClusterSecretStore, PushSecret, and related CRDs.
Security
Both the operator and webhook run with hardened securityContext: runAsNonRoot: true, runAsUser: 1000, allowPrivilegeEscalation: false, capabilities: drop: ALL, readOnlyRootFilesystem: true, seccompProfile: RuntimeDefault. Pod-level securityContext enabled. RBAC is cluster-wide for ClusterSecretStore usage.
Updates
Managed by Renovate, which bumps the OCIRepository tag. Flux verifies the chart artifact with cosign keyless (verify.provider: cosign): the signature must come from the GitHub Actions OIDC issuer with the external-secrets/external-secrets helm.yml workflow as subject.
Usage
One store, fed by SOPS
There is a single ClusterSecretStore, secrets-hub. It uses the Kubernetes provider and reads from the namespace of the same name through the ServiceAccount secrets-hub-sa, whose Role lists the readable Secrets by name. The Secrets in that namespace are ordinary SOPS-encrypted files in Git (k8s/platform/configs/talos/secrets-hub/), decrypted by Flux like any other.
Fan-out by namespace label
Each shared secret has a ClusterExternalSecret that selects namespaces by label and creates the Secret there:
| Namespace label | Secret delivered | Set by |
|---|---|---|
secrets.k8up: "true" | k8up-s3-credentials — Hetzner Object Storage key for the backup repositories | the backups component |
secrets.tofu-workspace: "true" | tofu-workspace-s3-credentials and backblaze-account-credentials — what a workspace needs to manage buckets and keys | the workspace component |
secrets.email: "true" | allinkl-smtp-credentials — the SMTP account | the email component |
secrets.netbird: "true" | netbird-token — NetBird API token | the app's own overlay |
secrets.docker-gitea: "true" | regcred-docker-gitea — pull secret for the Gitea registry | the app's own overlay |
An app never writes an ExternalSecret itself. It pulls in the component, the component labels the namespace, and the Secret appears.
When a secret goes through the hub, and when it doesn't
A secret that belongs to one app stays a SOPS file in that app's overlay. The hub is only for credentials that many namespaces need unchanged, where a copy per app would mean rotating the same value in forty files.
Adding one takes three edits in secrets-hub/: the SOPS-encrypted Secret, its name in the Role's resourceNames, and a ClusterExternalSecret with a new secrets.<name> label. Rotating one means re-encrypting a single file; External Secrets propagates the new value on its next refresh.
The edge cluster does not run External Secrets; its few secrets are plain SOPS files.
Cluster-specific deviations from the above live in the per-cluster README — see k8s/platform/external-secrets/talos/README.md.
Cluster Deployment
External Secrets — Talos cluster
Deviations from defaults
- Controller, webhook and cert-controller images pinned by digest (
external-secrets-values.yaml) —verify-platform-imagesruns withverifyDigest: true, so the tag-only chart default is rejected at admission and Flux cannot reconcile the HelmRelease.
- HelmRelease:
external-secrets@2.13.0 - HelmRepo:
external-secrets(oci://ghcr.io/external-secrets/charts/external-secrets)
Rendered manifests (kustomize build)
apiVersion: v1
data:
values.yaml: >
# yaml-language-server:
$schema=https://raw.githubusercontent.com/external-secrets/external-secrets/refs/heads/main/deploy/charts/external-secrets/values.schema.json
# All three External Secrets workloads (controller, webhook, cert-controller)
# are pinned by tag AND digest: the Kyverno policy verify-platform-images
# checks the cosign signature with verifyDigest: true, which rejects tag-only
# image references at admission — Flux's dry-run of the Deployments fails and
# the HelmRelease cannot reconcile. The chart has no digest value, so the
# digest rides in `tag` and renders `repository:tag@digest`. Renovate's
# helm-values manager parses `tag@digest` and moves both together; the chart
# version in base/helm-release.yaml is grouped into the same PR.
installCRDs: true
resources:
requests:
cpu: 10m
memory: 32Mi
grafanaDashboard:
enabled: true
image:
repository: ghcr.io/external-secrets/external-secrets
tag: v2.13.0@sha256:f799d87a6a959e597a4ba7a8d2379886292c3fcfbee678f7dc2d3566051bcb96
# Controller security context lives at the chart's top level (webhook and
# certController carry their own blocks below); nesting it under `image:`
# is silently ignored by the chart.
podSecurityContext:
enabled: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
enabled: true
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
webhook:
image:
repository: ghcr.io/external-secrets/external-secrets
tag: v2.13.0@sha256:f799d87a6a959e597a4ba7a8d2379886292c3fcfbee678f7dc2d3566051bcb96
podSecurityContext:
enabled: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
enabled: true
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
certController:
image:
repository: ghcr.io/external-secrets/external-secrets
tag: v2.13.0@sha256:f799d87a6a959e597a4ba7a8d2379886292c3fcfbee678f7dc2d3566051bcb96
kind: ConfigMap
metadata:
name: external-secrets-values-686c8k2t5f
namespace: external-secrets