ADR-018: Secrets come from External Secrets Operator; git holds references, never values
Status: Accepted
Date: 2026-10-07
Amended by: ADR-072: A generated credential is written exactly once, by a create-only Job that cannot write it twice (generate is written by a credentials Job, an exception to this ADR)
Builds on: ADR-017 (the GitOps repo owns every manifest)
Context
Section titled “Context”secretRefs only named Secrets that had to exist already; nothing created them. Secret values must not live in git: history is permanent, and every clone, fork and CI token of the GitOps repo would read the secrets of all environments. Sealed Secrets / SOPS keep encrypted values in git, but add a key-management burden (controller keys or an Argo plugin) and still make rotation a git change. The platform already assumes ArgoCD and external stores.
Decision
Section titled “Decision”- External Secrets Operator (ESO) creates every application Secret.
services.yamldeclares them per service undersecrets:;render.pyemits oneExternalSecretper entry (plus aPasswordgenerator when needed) and exposes the resulting Secret throughenvFrom.secretRefsstays for Secrets managed elsewhere. - Two modes, never mixed in one Secret:
generate: [KEY, ...]: ESO’sPasswordgenerator creates random values in the cluster, once per environment (refreshInterval: "0",creationPolicy: Owner). For secrets the product owns: DB passwords, signing keys, internal tokens. No store, no human, identical locally and on real clusters. Amended by ADR-072: A generated credential is written exactly once, by a create-only Job that cannot write it twice: generated values are no longer written by ESO; a create-only credentials Job writes each Secret exactly once (an exception to “every Secret comes from External Secrets”).remote:is unchanged. Deleting the Secret regenerates (rotates) the values on the next sync.remote: {keys: [...], path?}: read from thesecretStorenamed inapp.yaml(defaultClusterSecretStore/platform-secrets, path{app}-{env}-<secret>), refreshed hourly. For credentials someone else issues (payment, mail, third-party APIs).
- Local cluster:
cluster-upinstalls ESO with a k3sHelmChart(no helm binary; pinned chart version) and aplatform-secretsstore using ESO’s Kubernetes provider over thesecrets-storenamespace.cplat secret set|list(/gitops:secret) writes remote values there from a hidden prompt or stdin; they never touch git or a command line. - Real clusters bring their own ClusterSecretStore (Vault, AWS Secrets Manager, GCP Secret Manager, …) under the name configured in
app.yaml; the platform does not provision a backend. - Guard rail:
render.pyrejects secret-looking keys (*PASSWORD*,*TOKEN*,*API_KEY*, …) with a value inenv:and points tosecrets:.
Consequences
Section titled “Consequences”- A fresh service gets working credentials with one flag (
compose add --generate app-auth=DB_PASSWORD). - Isolation between environments by name convention in the local store only; production stores enforce it with their own policies.
- ESO becomes a cluster prerequisite for any service that declares
secrets:.