ADR 0001: Secret Management Approach¶
Status: Accepted Date: 2026-05-16 Ticket: UNI-49 (BD-0.3)
Context¶
The soa Laravel backend requires ~30-50 runtime secrets per tenant env (DB credentials, Redis URLs, API keys for Sentry/Bugsnag/Pusher/Typesense/Firebase, APP_KEY, etc.). These previously lived in AWS SSM Parameter Store and were injected via ECS task-definition environment variables.
Moving to DOKS (Kubernetes), we need a secret management strategy that:
- Is portable (not tied to AWS SSM or any single provider)
- Does not store secret values in Git or Terraform state
- Supports per-tenant config isolation
- Enables rotation without pod restarts (where possible)
- Works with External Secrets Operator (ESO) -> K8s Secret ->
envFrompattern
Decision¶
Doppler is the source of truth for all backend runtime secrets.
Project structure¶
One Doppler project per service:
| Project | Configs |
|---|---|
oep |
base, oep-stg, mansety-prd, us-prd |
base holds shared keys (Sentry DSN, Bugsnag API key, Firebase base config, Cloudflare Origin certs). Per-env configs (oep-stg, mansety-prd, us-prd) inherit from base and override with env-specific values (DB_HOST, DB_PASSWORD, APP_KEY, REDIS_URL, etc.).
Secret/config boundary¶
| What | Where |
|---|---|
| Runtime secrets (credentials, keys, DSNs) | Doppler only. Never in Git. Never in Helm values. |
| Static config (hostnames, replicas, resource sizes) | Helm values-<tenant>.yaml in infra-ops. Never in Doppler. |
| TLS certs (Cloudflare Origin certs) | Doppler base config (keys CF_ORIGIN_CERT_<host>, CF_ORIGIN_KEY_<host>). ESO pulls into K8s TLS Secrets. |
Service token flow¶
- Doppler service tokens generated manually in the Doppler UI (read-only scope, one per non-
baseconfig). - Token stored in 3 places only:
- Doppler itself (source)
- GH secret at
unipuka/soarepo level:DOPPLER_TOKEN_OEP_STG,DOPPLER_TOKEN_MANSETY_PRD,DOPPLER_TOKEN_US_PRD(CI usage) - K8s
Secretinexternal-secretsns:doppler-token-<env>(ESO usage) - TF creates the K8s Secret from
var.doppler_service_token_<env>(sensitive TF input, passed viaTF_VAR_*env at apply time, never committed). - TF does NOT manage service token lifecycle via the Doppler provider (avoids token values in TF state).
ESO sync model¶
Doppler config (oep-stg)
-> ClusterSecretStore (doppler-token-oep-stg K8s Secret)
-> ExternalSecret (per workload, refreshInterval: 60s)
-> K8s Secret (<release>-app-env)
-> Pod envFrom
One ClusterSecretStore per Doppler config (3 total). All workloads (api, worker, websockets, scheduler, migrate hook) reference the same ClusterSecretStore for their tenant.
Consequences¶
Positive:
- Secrets never in Git or Terraform state
- Per-tenant isolation at the Doppler config level
- Config inheritance (
base-> env) reduces duplication - Provider-portable: ESO + ClusterSecretStore pattern works with Vault, AWS Secrets Manager, etc. - swap provider without changing K8s manifests
- 60s refresh enables key rotation without pod restarts
- Doppler UI provides audit trail + access control
Negative/Trade-offs:
- Manual service token generation adds an ops step (mitigated by rotation runbook)
- TF must be re-applied when service tokens are rotated (to update the K8s Secret)
- ESO refresh lag of up to 60s before pods see a rotated secret
Alternatives Considered¶
AWS Secrets Manager via ESO: Considered but rejected. Tight coupling to AWS; migration goal is to reduce AWS dependency.
HashiCorp Vault: Out of scope per plan Section 3. Would require additional infra to operate.
SOPS + AGE keys in Git: Rejected. Encrypted blobs in Git create key management complexity and the decryption key becomes the new secret.
GH Org-level secrets as primary store: Current state on AWS. Rejected for DO because secrets cannot be synced to K8s without an additional bridge; also org-level scope is wider than needed.
Rotation Runbook¶
See docs/runbooks/rotate-secrets.md.