Skip to main content

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

ToolOpen SourceFull FeaturesNotes
Sealed SecretsYesYesEncrypts secrets in Git; no external store needed
Vault Agent InjectorYesYesRequires HashiCorp Vault; sidecar injection model
SOPS + FluxYesYesGit-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 labelSecret deliveredSet by
secrets.k8up: "true"k8up-s3-credentials — Hetzner Object Storage key for the backup repositoriesthe backups component
secrets.tofu-workspace: "true"tofu-workspace-s3-credentials and backblaze-account-credentials — what a workspace needs to manage buckets and keysthe workspace component
secrets.email: "true"allinkl-smtp-credentials — the SMTP accountthe email component
secrets.netbird: "true"netbird-token — NetBird API tokenthe app's own overlay
secrets.docker-gitea: "true"regcred-docker-gitea — pull secret for the Gitea registrythe 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-images runs with verifyDigest: true, so the tag-only chart default is rejected at admission and Flux cannot reconcile the HelmRelease.
Kubernetes Metadata
  • 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