DFE on AWS -- configuration
What the AWS root builds, how the dial sizes it, and the Kafka and ClickHouse choices behind it. The deploy steps are in aws.md.
- What every deployment builds
- Sizing the deployment
- Resolving sizing artefacts
- Sizing dials and locks
- What the apply builds
- Kafka and ClickHouse
What every deployment builds
terraform/environments/aws builds an EKS cluster and hands it to the same bootstrap and Argo CD layers every other cluster uses. Layer 1 and Layer 2 are the same on AWS.
- ARM only. Every node pool, and the MSK broker shape when one is built, resolve to Graviton. The cluster module refuses a
shape_refwhose resolved architecture isn't arm64. - Nodes and the data plane sit in private subnets. By default they share one NAT gateway (
network.nat: single). Use one per availability zone (per-az) when a zone failure or the cross-zone charge costs more than the extra gateway. network.az_count(default 3, range 2-6) sizes the VPC and is one of the six fieldssizing.yamllocks once resolved (Sizing dials and locks). A Strimzi or MSK broker count steps up to at least the AZ count. So a deployment spanning more zones than the tyre-kick floor's three brokers still gets one broker per zone.- The Kubernetes API's private endpoint is always on.
endpoint.public: trueadds the public one, and only alongsideendpoint.allowed_cidrs. The plan refuses a public endpoint with no allow-list. - A gateway VPC endpoint keeps S3 traffic off the NAT path at no extra charge.
- The account's state bucket is a one-off. Run
terraform/environments/aws-stateonce, before any deployment applies. - Every controller that needs an AWS credential gets it through EKS Pod Identity, tied to its own service account by the cluster module. That's the EBS CSI driver, Karpenter, the AWS Load Balancer Controller, external-dns and cert-manager. Nothing here carries a static key.
- One file drives all of it, the deployment dial
deployment.yaml. Copy it fromdeployment.example.yamlor letdfe-ops initwrite it (wizard.md).
external-dns
external-dns publishes only what the gateway chart marks. Its sources include Gateway API routes, and the Gateway admits routes from every namespace. Without a filter, anyone with namespace-level access could attach an HTTPRoute to the wildcard listener and get a name published under your domain with a public certificate behind it. So the addon carries annotationFilter: dfe.hyperi.io/publish-dns=true. helm/edge/gateway writes that annotation on every route it renders and on the managed proxy's own Service. A route from a deploy-repo overlay is published only if it carries the same annotation.
external-dns runs policy: sync with a per-deployment txtOwnerId (argocd/appsets/layer1-addons.yaml), so a deleted Service or Ingress has its record removed. The chart's own default, upsert-only, never deletes anything it published. Sync covers every teardown except tofu destroy itself. There the EKS cluster and every node go down in the same run, so whatever external-dns last published stays in the private zone with no controller left to clean it up.
terraform/modules/kubernetes-cluster/aws/dns.tf has a destroy-time cleanup for that case. It empties the zone of everything but its own apex NS/SOA pair, using the aws CLI directly, before the zone itself is destroyed. It runs under whatever AWS identity tofu destroy is already using. The one extra prerequisite is the aws CLI on the machine running the destroy. The cluster module's destroy-time sweep of leaked CNI network interfaces (eks.tf) needs it too, and a destroy without the CLI fails.
Sizing the deployment
- Copy
deployment.example.yamltodeployment.yaml, or rundfe-ops init. Every AWS-relevant field is documented inline, including the workedcloud_aws:example block near the bottom. - Estimate
sizing.ingest_gb_per_dayin GB/day, or leave it blank for the tyre-kick floor. That's the smallest shape that runs the profile without an out-of-memory kill. It reports whatever throughput it happens to carry and doesn't target one. - On AWS economy focus with
kafka.provider: msk, the resolver's own live run puts that floor at the smallest shapes it accepts, S, with the managed brokers the largest single line. That'sm8g.largefor eks-system,m8g.xlargefor general,r8gd.largefor clickhouse,m8g.largefor keeper, threeexpress.m7g.largefor msk-broker, andc8gd.4xlargefor ci-burst. - With Kafka in the cluster (
kafka.provider: strimzi) the floor loses that line and stays S, with kafka-broker onm8g.xlarge. Its 8 GiB is more than anm8g.largeleaves a pod. That's the default combined quorum.kafka.controller_pool: separateadds threem8g.largecontrollers. - Both buckets come from on-demand Linux pricing in the AWS Pricing API, read 2026-09-13. Rates drift, so the resolver's own
sizing/<profile>.report.md(written under--out) is the current answer. - At the floor, the resolver's A4 check WARNS and doesn't fail.
r8gd.largeandm8g.largesustain only 3,600 IOPS / 79 MiB/s on their EBS baseline and can burst above it for 30 minutes a day, while ClickHouse and Keeper write continuously. The floor is the smallest shape that runs, not a throughput target. Setsizing.ingest_gb_per_dayonce you know it, and the resolver sizes for the workload instead of the minimum.
Resolving sizing artefacts
- Run
python3 scripts/resolve_sizing.py --dial deployment.yaml --live --out terraform/environments/aws. Or resolve with--fixtures scripts/tests/fixtures/sizing --cloud awsagainst a captured catalogue, with no AWS call. For a region other than us-west-2, capture one first withresolve_sizing.py capture --region <region> --fixtures <dir>. - Every artefact lands under
--out(the repo root if you leave it out).<out>/shapes/resolved/aws-<region>.jsonis the instance-type answer. It's keyed by region because instance generations aren't available everywhere. <out>/sizing.auto.tfvars.jsonsits at the ROOT of--outand holdsnode_poolsandresolved_shapes, nothing the root doesn't declare. It's never named<tier>.auto.tfvars.json, so it sorts afterrender_dial.py --tofu's owndial.auto.tfvars.jsonwhatever tier was resolved.tofu initloads every*.auto.tfvars.jsonin that directory, in name order.- The resolver is the ONE writer of both variables. It starts from the dial's own
node_pools.systemblock (the always-on group you size by hand) and merges the pools it derives on top.render_dial.py --tofuemits neither key, so the two producers never collide on a tfvars variable. - Under
<out>/sizing/you get<tier>.values.yaml, its<tier>.data-charts.values.yamlsubset,<tier>.report.md(what was sized, from which ratio, in which cost bucket) andresolved.yaml(the baseline the next resolve diffs against). An on-prem resolve adds<tier>.nodes.json, the demandbootstrap.sh's preflight checks the cluster's real nodes against. - Copy
<out>/sizing/into the deployment repo's ownsizing/and commit it, so a re-size has something to check itself against.dfe-ops upgrade --dialdiffs a re-resolve against<deploy>/sizing/resolved.yaml, never against--out. layer2-data reads its<tier>.data-charts.values.yamlunderinfra/. bootstrap.shreads<out>/sizing/from the same place and seeds the data-charts file into a bundled repo. A resolve run anywhere else needsDFE_SIZING_DIRset to that directory. On a cluster running Karpenter, bootstrap REFUSES when<tier>.pools.jsonisn't there. The node-pools chart also refuses to render with no pool classes, rather than prune the cluster's NodePools.
Sizing dials and locks
sizing.focusbuys headroom over the sized peak:economy(40%),balanced(60%) orperformance(100%). None of the three trades away RF3 ormin.insync.replicas=2, and none decides where the KRaft metadata quorum runs. That'skafka.controller_poolbelow.sizing.overridesis your per-workload override block, applied last, over whatever the ratios derived. Storage-cap assertions also check overridden values.- The
locked:section ofsizing.yamlnames six fields that lock once resolved. They arepartition_count,storage_model,kafka_provider(recorded from the dial'skafka.provider),controller_mode(combined against separate KRaft controllers,kafka.controller_pool),cloud_tokenandaz_count. The resolver records each insizing/resolved.yaml(az_countonly when it resolved against a cloud catalogue). Give a re-resolve--previous <deploy>/sizing/resolved.yamland it refuses, exiting 3, if any of them moved. Pass--migrateas well if you mean it. - Four of the six also have a chart key. The deployment repo holds those keys as protected vars, so the engine's API refuses an edit unless the caller holds
helmvars:override.governance/policies/sizing-locks.yamlcovers three:kafka.sizing.*(the partition count derives from it),kafka.controllerPool.enabledandcloud.storage-layout.yamlcovers the storage model (clickhouse.storageModel,kafka.storageModel). Neither policy governs a direct git commit. - The Kafka provider and the AZ count are OpenTofu inputs with no chart key, so the resolver's lock is the only one they get. The MSK module refuses any broker shape that isn't MSK Express (
terraform/modules/managed-kafka/msk/variables.tf).network.az_countfeeds the VPC's zone list interraform/modules/kubernetes-cluster/aws/vpc.tf. - Above 100,000 GB/day the resolver refuses outright and points you at a professional-services engagement, not a generated profile.
What the apply builds
python3 scripts/render_dial.py --tofuwritesterraform/environments/aws/dial.auto.tfvars.jsonfrom the dial'starget.provisionblock.tofu initreads the state bucket, key and region straight from that file, so there's no separate-backend-configto keep in step.- The root creates the VPC and its subnets, the EKS cluster and its node groups, the managed Kafka when the dial asks for one, the deployment's KMS key, the Route 53 zones and the Secrets Manager store. Nothing that runs inside Kubernetes.
- The deploying principal's EKS access entry uses
data.aws_iam_session_context.issuer_arn, so an SSO permission set, an assumed role, an instance profile or a Lambda role all work. The entry names the IAM role, with itsaws-reserved/sso.amazonaws.com/<region>/path for SSO, not the STS session ARN. - A managed broker builds BESIDE the cluster, not after it. The Kafka module takes the network, the cluster name, the KMS key and the Pod Identity trust policy as explicit inputs, and the key's ARN carries the ordering against the key policy that a broker's first log delivery needs. So nothing holds the broker behind the control plane, the node groups or the addons. It's still the longest resource in the deployment, so it decides when the apply ends.
tofuwrites NO outputs to the state until the whole apply finishes.--from-terraformcan't read them while the broker is still creating, so the deploy waits for the apply to end even though bootstrap touches no broker at all. Two applies get round it: one-target=module.clusterto land the cluster outputs, then the full apply. The broker's own annotations are only written by a bootstrap run after it exists, so that route costs a second bootstrap pass. Take the single apply unless the second pass is worth it to you.dfe-ops stack-deploy --from-terraform terraform/environments/awsassembles the bootstrap env from the outputs, sets the stack version, runs the offline pin, drift and render preflight, thenbootstrap.sh, the readiness gate and the two default data checks.--check-onlyruns the preflight alone and never touches the cluster.bootstrap/bridge.pyis the reader it imports, not a command you run.- The secrets store is Secrets Manager (
aws-sm). The root composes no other, andstack-deployrefusesDFE_SECRETS_BACKEND=openbaoon AWS. k8s.repo_urlis the dfe-infra chart repo Argo reads. A private copy needsDFE_REPO_TOKEN, an HTTPS token with read access thatbootstrap.shturns into the Argo repository credential. Without it every Layer 2 Application staysUnknownwith "authentication required". Export it, or carry it in an--env-file. The dfe-* images and thin charts pull anonymously.- When
k8s.storage_classnames no class the cluster already has,bootstrap.shcreates it. It uses provisionerebs.csi.aws.com, gp3 baseline 3,000 IOPS / 125 MiB/s, encryption with the account's default EBS key, andWaitForFirstConsumer. EKS 1.30+ ships onlygp2with no default class, and the CSI add-on creates none itself. The new class becomes the cluster default only when the cluster has none, so an existing default stays. - The same run's internal-CA persist step renders the gateway chart with the same value files Argo layers afterwards:
argocd/values/common.yaml,argocd/values/<cloud>.yaml,argocd/values/profile-<profile>.yaml. - The AWS Load Balancer Controller gets
vpcId(theDFE_VPC_IDoutput, carried on the cluster secret asdfe.hyperi.io/vpc_id) andregionexplicitly. It doesn't discover them. Node IMDS hop limit stays at 1.
Kafka and ClickHouse
Every managed backend on AWS is the vendor's AWS instance. The plan refuses a backend AWS doesn't offer, and a ClickHouse Cloud service on another cloud. managed-backends.md has the rule, the per-cloud table and the egress ports.
Kafka
All five kafka.provider tokens validate on both the resolver and the OpenTofu root: strimzi, redpanda, msk, confluent-cloud and redpanda-cloud. The root calls the module body the dial names, each behind its own count. Only name, env and network hold for every body whatever the provider. dfe-engine creates the topics on every provider. kafka/README.md has the defaults DFE runs Kafka with.
mskruns MSK Express brokers, and only Express, because MSK can't convert a Standard cluster to Express later. They authenticate both ways. Every DFE app uses SASL/SCRAM. The in-cluster bootstrap Job alone uses SASL/IAM, because Kafka ACLs live in the data plane and tofu can't write the first one. That Job writes only the SCRAM principal's ACLs and exits non-zero on any failure.- MSK's broker-count scaler, its telemetry sink and its rebalancing behaviour are operational detail, not part of choosing a provider. See Kafka telemetry and autoscaling in aws-operations.md.
- The
confluent-cloudandredpanda-cloudbodies live underterraform/modules/managed-kafka/, tested against the same module rules asmsk/. Neither sizes, versions or scales itself from a shape the resolver picks. - The vendor API credential is a provider environment variable, never a tfvar. That's
CONFLUENT_CLOUD_API_KEYandCONFLUENT_CLOUD_API_SECRETfor Confluent,REDPANDA_CLIENT_IDandREDPANDA_CLIENT_SECRETfor Redpanda Cloud. Export both pairs beforetofu plan, because theredpandaprovider checks for its pair at configure time whicheverkafka.provideryou chose. confluent-cloudchecksmessage_max_bytesagainst its tier's ceiling. Its default tier (Freight) is a Private Network Interface, not PrivateLink, and caps a topic'smax.message.bytesat 20 MB, so it carries the chain's 16 MiB. Basic and Standard cap it at 8,388,608. The root'sDFE_KAFKA_MESSAGE_MAX_BYTESoutput hands the dial's size to dfe-engine, which creates every topic at it.redpanda-cloudtakes the shared SCRAM password and the ephemeral-lifecycleallow_deletionflag. Its only creatable tier is Serverless, over a PrivateLink-style endpoint, and its SCRAM password reuses MSK's own seed.- Hand-written OpenTofu for both is in kafka/confluent-cloud.md and kafka/redpanda-cloud.md, for a deployment that stands the service up some other way.
strimziruns in the cluster exactly as it does anywhere else. Its broker autoscaling is a KEDAScaledObjecton theKafkaNodePool/scalesubresource. It triggers on consumer lag by default, with scale-in off.
ClickHouse
Coming from Splunk or Elastic? With the default
cached-objectstorage model there are no hot, warm and cold tiers to move data between. ClickHouse keeps its parts in S3 and serves repeated reads from a local NVMe cache (storage.md).
- ClickHouse's storage model dial (
sizing.storage_model) defaults toauto. That means object store wherever an endpoint exists, withlocalthe explicit opt-out. On AWS it resolves tocached-object. Parts sit in the S3 bucketterraform/modules/kubernetes-cluster/aws/object-store.tfprovisions, authenticated through its own Pod Identity role and not a static key, behind a local read-through cache. - The AWS ClickHouse shape pins a local-NVMe family (
r*d, generation floor 8). The resolver sizes a dedicated instance-store cache disk for it and pointsclickhouse.objectStore.cache.volumeatinstance-store, so the cache runs on that NVMe and doesn't share the data PVC. storage.md has the mechanism and its default for any shape with no such NVMe (pvc, bounded at 60% of the data PVC's space). - Both node paths assemble that NVMe as one RAID0 array under the kubelet. The Karpenter pool does it through
instanceStorePolicy, the managed node group through a nodeadm NodeConfig its launch template carries. The cache takes 0.8 of it (clickhouse.instance_store_cache_share). The bucket, the cache placement and the storage model all reach the chart on the cluster secret, so a fresh deploy repo needs no overlay for any of them. clickhouse.mode: externalconnects to a server you brought, ClickHouse Cloud first, and deploys none. The dial'sclickhouse.hosttravels asendpoints.clickhouse_host. The root hands it, the ports, TLS and the user to the cluster secret asdfe.hyperi.io/clickhouse_*. layer2-data, layer2-platform and layer2-apps turn those into theclickhousevalues for clickhouse-cluster, network-policies, the otel collector and every app.- The external ClickHouse password is
TF_VAR_clickhouse_external_password, never a dial field. The secrets module seeds it at<project>/<env>/clickhouse/external, and clickhouse-cluster copies it into the engine's namespace asclickhouse-admin-password. - A ClickHouse Cloud service's IP access list has to admit the cluster's NAT egress, the root's
DFE_EGRESS_CIDRSoutput. - Retention starts from a 24-hour assumed consumer downtime on the sized (
scale) tier, plus anyarchiver_lag_hours.singleisn't sized and keeps the chart's 24-hour default (kafka.retention.assumedConsumerDowntimeH), andslimruns no broker. DLQ topics hold 7 days (168 hours) whatever the tier.