Transmission 033 · 2026-09-09

The cluster was clean. OpenTofu installed. The Terraform config validated and the Helm chart linted.

032 ended with every pod at 1/1 Running and the full pipeline clean. Today the goal was to build the AWS EKS infrastructure layer: Terraform files for VPC, cluster, and ECR repositories, and a production Helm values override for the move off Minikube. The first task was getting a working IaC tool — HashiCorp Terraform refused to download due to regional trade controls. Went to OpenTofu instead. Got the binary, added it to PATH, aliased it as terraform. Wrote four Terraform files and a values-eks.yaml, then validated both. terraform validate returned success. helm lint returned 0 failed. The cluster is still running on Minikube. The EKS config is ready to apply when AWS credentials are available.

Transmission 032 ended clean: every pod at 1/1 Running, POST → worker → GET working, idempotency confirmed.

Today the goal was one layer up. Write the infrastructure that moves NexusFlow off Minikube and onto AWS EKS.


The constraint

The stack needs a VPC, an EKS cluster, a managed node group, and two ECR repositories — one for the gateway image, one for the worker. The node group has to be sized for the actual memory footprint, which is tight on a t3.medium. The Helm values file needed to be rewritten for cloud conditions: ECR image URLs, cloud-speed probe delays, strict memory limits, no imagePullSecrets required.

Before any of that, I needed a working IaC tool on this machine.


The proof

Getting OpenTofu onto the machine

winget install HashiCorp.Terraform
Downloading https://releases.hashicorp.com/terraform/1.15.8/terraform_1.15.8_windows_amd64.zip
An unexpected error occurred while executing the command:
Download request status is not success.
0x80190194 : Not found (404).

The winget package pointed to a version that does not exist at that URL. Tried downloading directly from HashiCorp’s release server next:

Invoke-WebRequest -Uri "https://releases.hashicorp.com/terraform/1.10.5/terraform_1.10.5_windows_amd64.zip" `
  -OutFile "C:\terraform\terraform.zip"
Invoke-WebRequest : This content is not currently available in your region.
Please see trade controls.

HashiCorp’s download servers are blocked in this region. I went to OpenTofu instead — the open-source fork of Terraform that predates the BSL licence change. Same HCL syntax, same provider registry, compatible with every module we use.

$release = Invoke-RestMethod -Uri "https://api.github.com/repos/opentofu/opentofu/releases/latest"
$asset   = $release.assets | Where-Object { $_.name -like "*windows_amd64.zip" } | Select-Object -First 1
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile "C:\terraform\tofu.zip"
Expand-Archive -Path "C:\terraform\tofu.zip" -DestinationPath "C:\terraform" -Force
Copy-Item "C:\terraform\tofu.exe" "C:\terraform\terraform.exe" -Force
$env:Path += ";C:\terraform"
terraform -v
OpenTofu v1.12.6
on windows_amd64

Copying the binary to terraform.exe means every script and shell alias continues to use terraform without modification. The terraform validate and helm lint commands below both ran through OpenTofu.


The Terraform files

Four files in infra/terraform/.

versions.tf pins Terraform core to >= 1.9.0, < 2.0.0 and the AWS provider to ~> 5.62. The tls and kubernetes providers are also pinned. An S3 backend block is commented out — it needs an existing bucket before terraform init can use it, and there is no AWS account configured on this machine yet.

variables.tf exposes the knobs: region, cluster name, 2 AZs, private and public subnet CIDRs, EKS version, node instance type, desired/min/max node counts, ECR repository names, and image tag mutability. Every variable has a type constraint. The AZ list has a validation block that rejects anything other than exactly two entries.

main.tf is three layers.

Layer one uses terraform-aws-modules/vpc to build the network: two private subnets for nodes, two public subnets for the NAT gateway and load balancers, EKS subnet discovery tags on both sets. A single NAT gateway rather than one per AZ — that saves roughly $32 a month. For a non-production cluster running in short bursts, the HA tradeoff is worth it.

Layer two uses terraform-aws-modules/eks at version ~> 20.24. The control plane runs at v1.31. One managed node group: t3.medium, ON_DEMAND capacity, scaling 1 to 2 nodes. AmazonEC2ContainerRegistryReadOnly is attached directly to the node IAM role, which means every pod on the node can pull from ECR without a Kubernetes imagePullSecret configured anywhere. The VPC CNI add-on gets before_compute = true so the network plugin is ready before any node joins.

Layer three creates two ECR repositories — nexusflow-gateway and nexusflow-worker — with scan-on-push enabled and a lifecycle rule that expires untagged images after one day and retains the last ten tagged releases.

outputs.tf surfaces four things: the configure_kubectl command, the docker_login_command, the full ECR repository URLs for each image, and a helm_deploy_command with the repository flags pre-filled from Terraform state.


Validating the config

export PATH="$PATH:/c/terraform"
cd /d/nexusflow/infra/terraform
terraform init -backend=false
Downloading registry.opentofu.org/terraform-aws-modules/eks/aws 20.37.2 for eks...
Downloading registry.opentofu.org/terraform-aws-modules/vpc/aws 5.21.0 for vpc...
Installed hashicorp/aws v5.100.0
Installed hashicorp/kubernetes v2.38.0
Installed hashicorp/tls v4.4.0

OpenTofu has been successfully initialized!
terraform validate
Success! The configuration is valid.

The Helm values file

values-eks.yaml overrides values.yaml for cloud conditions. The decisions that needed explicit justification:

Memory requests stay under 3.1 GiB total. A t3.medium has 4 GiB of RAM. The kubelet and system pods consume around 350 MiB. That leaves approximately 3.7 GiB allocatable, but scheduling headroom is needed for rolling updates — a second gateway or worker pod lands before the old one terminates. The budget across all app pods:

PodReplicasRequestTotal
gateway2192 Mi384 Mi
worker2192 Mi384 Mi
postgres1384 Mi384 Mi
redis1128 Mi128 Mi
db-migrate1 (job)64 Mi64 Mi
app total1 344 Mi

That comes to roughly 1.7 GiB scheduled including system overhead. The rest is headroom.

No CPU limits on any application pod. t3.medium earns CPU credits at 0.2 vCPU per hour per core at baseline and bursts to the full 2 vCPU when credits allow. Setting a CPU limit would cap the pod at the limit value even when credits are available, introducing artificial throttling. Requests are set for scheduler packing; limits are omitted so bursts flow freely. Postgres and Redis have memory limits set strictly — both will OOM-kill themselves rather than evict neighbouring pods, which is the right failure mode.

imagePullPolicy: Always on gateway and worker. ECR images are content-addressable, but Always pull forces the kubelet to check the registry digest on every pod start. On Minikube, IfNotPresent was correct because images were built directly into the local daemon. On EKS, the node may be replaced at any point and will have no cached layers. Always is safer.

namespaceCreate: false. Terraform creates the nexusflow namespace as part of the apply, before Helm runs. Letting Helm also try to create it would cause an ownership conflict identical to the one in 031.

initialDelaySeconds: 30 on every probe. Minikube pods start on a node that has been running for months. EKS nodes start cold: EBS volume attachment, kubelet registration, CNI initialisation, image pull from a remote registry. The 10–15 second delays in values.yaml cause false-positive liveness failures in that environment. 30 seconds is enough for the worst-case cold start we have seen.

createSecret: false. The nexusflow-secrets Secret must be created externally before Helm runs — either via a kubectl create secret one-liner pulling from AWS Secrets Manager, or via an External Secrets Operator ExternalSecret CR. The values file documents both. No password appears in any YAML file that gets committed.

cd /d/nexusflow
helm lint ./charts/nexusflow -f ./charts/nexusflow/values-eks.yaml
==> Linting ./charts/nexusflow
[INFO] Chart.yaml: icon is recommended

1 chart(s) linted, 0 chart(s) failed

The INFO about the icon is cosmetic. Zero failures.


The cluster is still Minikube

The stack came up clean this morning on Minikube before the infrastructure work started:

minikube start
eval $(minikube docker-env)
kubectl get pods -n nexusflow
NAME                       READY   STATUS    RESTARTS        AGE
gateway-557cf49778-cdb87   1/1     Running   1 (9m31s ago)   46h
postgres-0                 1/1     Running   4 (4m6s ago)    3d19h
redis-0                    1/1     Running   2 (9m31s ago)   3d19h
worker-6797777459-m6qsx    1/1     Running   1 (9m31s ago)   46h

Direct curl http://127.0.0.1/health failed — minikube tunnel was not running. Port-forwarding confirmed the gateway was alive:

kubectl port-forward svc/gateway 8080:80 -n nexusflow
curl http://127.0.0.1:8080/health
{"service":"gateway","status":"ok","timestamp":"2026-09-09T06:18:55.126356+00:00"}

Everything from 032 is still running. The EKS infrastructure is ready to apply when AWS credentials are in place.


Where this sits

ItemStatus
OpenTofu installed and on PATHDone
versions.tf — provider pinsDone
variables.tf — all tuneable knobs with validationDone
main.tf — VPC, EKS, ECR, namespaceDone
outputs.tf — kubectl command, docker login, ECR URLsDone
terraform init -backend=falseClean
terraform validateSuccess
values-eks.yaml — ECR images, memory budget, probe delays, external secretsDone
helm lint with EKS values0 failures
terraform apply against a real AWS accountPending

The config is valid. The next step is terraform apply.