Skip to main content

DFE Backing Services - swap every backend at a seam

DFE is a generic product that ships into clusters we never touch. So every backing service it stands on - the secrets store, the streaming bus, the object store, the load balancer, the IdP - has to be swappable without rewriting the product. This document is the itemised list of those backing services, the seam each one swaps at, and which concrete backend fills that seam per target.

("Backing services" is the Twelve-Factor term: attached resources treated as swappable, bound by config, never hardcoded.)

The one rule​

Every backing service has its own seam, so each swaps without touching the others or the engine. There is no monolithic "on-prem build" vs "cloud build" - there is a per-service choice, and any mix is valid ("hybrid" is not a special case, it is just picking cells from different columns).

Two ownership lanes, applied to every backing service:

  • dfe-infra deploys or wires the concrete backend, and injects the config that selects it.
  • dfe-engine codes only to the seam (a standard interface + config), never to a named product.

Design FOR, build WITH (on-prem only, now) - strict YAGNI​

  • Design FOR the swap: pick a seam that is already a standard, multi-backend interface (scalo.secrets, the S3 API, OTel, the cert-manager Issuer, the k8s Gateway API, Argo). Choosing the standard interface and not hardcoding a product IS most of the design work - there is almost no bespoke abstraction to build.
  • Build WITH on-prem only, now: implement and wire just the on-prem column. The cloud cells are real seams with deferred implementations - we fill them when we actually do AWS, then GCP, then Azure, not before.
  • YAGNI: no speculative plugin framework, no factory-of-factories, no cloud code paths for clouds we have not built. The seam is the thinnest thing that makes the swap a config change.
  • The engine carries no per-cloud branches. Any if aws: ... elif gcp: ... belongs in dfe-infra config/wiring, never in dfe-engine. The engine stays platform-agnostic; the exception handling lives where the platform is known.

Two axes (do not conflate them)​

Size and platform are orthogonal. Size sets composition; platform sets which product fills each seam.

  • Size = the three dfe-infra profiles (argocd/values/profile-slim|single|scale.yaml) plus the non-k8s dfe-docker. It changes replica counts, single-node vs cluster, which optional apps run - not which product a seam binds to.
  • Platform = the swap dimension (argocd/values/local|aws|gcp|azure.yaml). terraform/environments/ has only local today: on-prem is the one built target.

The matrix​

Rows are backing services; the seam is what the engine/infra codes to; the platform columns are the concrete backend. On-prem is built now; cloud cells are seam-ready, implementation deferred (YAGNI).

Backing serviceSeamon-prem / Rancher (now)AWSGCPAzuredfe-docker
Cluster / computek8s APIRKE2 / RancherEKSGKEAKSsingle host
Secretsscalo.secrets providerOpenBaoSecrets ManagerSecret ManagerKey Vaultfile / ansible_vault
Data store (CH)CH gateway + effective_data_databaseDFE CH (single/cluster)ClickHouse Cloud / self-mgdClickHouse Cloud / self-mgdClickHouse Cloud / self-mgdCH container
Streamingscalo.kafkaStrimzi / RedpandaMSK / ConfluentConfluent / Pub-Sub bridgeEvent Hubs (Kafka API)Redpanda (or grpc-direct)
Object storeS3 APIMinIOS3GCSBlobMinIO / local FS
Auth / IdPX-Oidc-* header contractKeycloak / Authentik / localCognitoGoogle IdentityEntra IDlocal-auth / oauth2-proxy
Ingress / LBGateway API / ServiceMetalLB + Envoy GatewayALB / NLBGCLBAzure LB / App Gatewayhost ports / Caddy
TLS / PCAcert-manager Issuercert-manager + OpenBao PKIACM / Private CAGoogle-managed / CASKey Vault / App Gateway certsself-signed / mkcert
GitOps repogit remote URLin-cluster ForgejoCodeCommit / keep Forgejokeep Forgejokeep Forgejolocal git / bind-mount
Reconciler (CD)Argo Application / AppSetArgo CDArgo CDArgo CDArgo CDcompose render (none)
RegistryOCI refHarborECRGARACRDocker Hub / local
Block storageStorageClassLonghorn / local-pathEBSPDAzure Diskdocker volumes
DNSexternal-dns / zoneCoreDNS / site DNSRoute 53Cloud DNSAzure DNS/etc/hosts
Observability sinkOTel exporter -> destHyperDX + CHHyperDX (keep) or CloudWatchHyperDX (keep) or Cloud MonitoringHyperDX (keep) or Azure MonitorHyperDX / console

Priority backing services for the first swap passes: secrets, DNS, PCA/TLS, k8s, Kafka, ClickHouse. The rest are mostly dfe-infra config with little or no engine code.

Worked seam - secrets (the pattern for all of them)​

The engine builds one manager from config and never names a product. dfe-infra sets the provider per target. This is the whole trick, applied to every row above.

The engine's JWT signing key, ClickHouse passwords and the OIDC client secrets an operator enters all go through scalo.secrets. With no DFE_SECRETS_PROVIDER they land in the file provider under DFE_SECRETS_PATH, which is all dfe-docker needs.

DFE_SECRETS_PROVIDER=aws keeps them in AWS Secrets Manager as <DFE_SECRETS_NAME_PREFIX>/<path> in DFE_SECRETS_REGION. Each is encrypted with DFE_SECRETS_KMS_KEY_ID (the AWS managed key when unset, with a startup warning), tagged with the DFE_SECRETS_TAGS JSON object, and deleted with no recovery window unless DFE_SECRETS_RECOVERY_WINDOW_DAYS sets 7-30. AWS finishes a forced delete in the background, so re-creating the same name retries for up to 30 seconds. Inside a window the name stays taken: re-creating that OIDC provider answers 409 with the remedy, and a reconcile re-creating that group fails naming the setting.

openbao is used where the deployment configures it. gcp and azure are refused: their secret names allow no /, and every engine path has one.

dfe-receiver reads its ingest bearer tokens from the same backend, so the backend has to be one the receiver reaches too. On openbao and aws it reads them where the engine wrote them, given a policy or role granting ingest/bearer-tokens/* and nothing else. The file directory sits on the engine's own volume, so the deployment mounts <DFE_SECRETS_PATH>/ingest/bearer-tokens read-only into the receiver at /var/run/secrets/dfe/ingest-bearer-tokens, as a directory so a rewrite reaches it, never the whole secrets root. On Kubernetes no receiver pod can mount the engine's volume. There DFE_INGEST_BEARER_SECRET names a Secret the engine also writes each listener's lines into, one key per listener, through scalo's kubernetes provider with get and update on that one Secret, and the receiver mounts the Secret read-only at the same path. ansible_vault cannot serve the receiver, and an ingest credential on it fails the receiver compile.

The dfe-docker special case - keep it small​

dfe-docker is the primary developer-desktop use case and is Kaz's repo (changes go via branch + PR). It must stay as small as possible: it is never production, so every seam takes its lightest backend - no OpenBao, no ESO, no Argo, no HyperDX dependency unless a dev opts in.

Levers that keep it small: Redpanda over Strimzi (or grpc-direct to skip the bus entirely), single-node ClickHouse, the file secrets provider, host ports instead of a gateway, self-signed TLS. Adding a heavier backend to dfe-docker is a smell - push weight to the k8s profiles, not the desktop.

Build order + seam status​

Backing serviceSeam statusNow (on-prem)Deferred (cloud)
Secretsready (scalo.secrets) - the engine mints through itfile / OpenBaoaws built; gcp/azure refused
ClickHousepartial - the CH gateway consolidation is the seamDFE CH modesCloud endpoints
Kafkaready (scalo.kafka)Strimzi / RedpandaMSK / Confluent / Event Hubs
Object storeready (S3 API)MinIOS3 / GCS / Blob
Auth / OIDCready (X-Oidc-* contract)Keycloak / localcloud IdPs
Ingress, TLS, DNS, storage, registry, reconcilerready (standard k8s interfaces)on-prem wiringcloud wiring in dfe-infra
Observabilityready (OTel exporter)HyperDX + CHkeep HyperDX or cloud sink

Only the ClickHouse gateway (the consolidation that also carries RBAC + audit) needs real engine work. Everything else is "use the standard interface + dfe-infra config".

Next - reduce swap complexity and exception handling​

With the seams itemised, the follow-on work is to drive down the cost of a swap: keep every swap a config change, not a code branch; centralise the few genuine per-platform exceptions in dfe-infra values, not in dfe-engine; and delete any existing per-product coupling that bypasses a seam. That design pass is tracked separately.