EKS Validation on a Real AWS Account: Twenty Cents a Run, Seven Failures No Mock Saw
By Vladimir Mikhalev · Docker Captain · CNCF Ambassador · IBM Champion
Twenty cents. That is what it cost on 15 October, by the run’s own list-price estimate, to prove an EKS reference platform end to end on a real AWS account: 101 resources applied, all 19 Argo CD Applications Synced and Healthy on EKS 1.37, 14 checks passed, everything destroyed and nothing left behind. The seven runs before it, the same night, failed on seven things that the platform’s mocked Terraform tests and its daily suite on kind had passed for weeks. Every one of the seven is now a check.
In October I wrote that most of a Terraform posture can be tested with no AWS account at all, and that what a plan cannot see belongs in a separate, slower suite against a sandbox account. This is that suite’s first night. It is the lesson of planting bugs in infrastructure tests, one layer further out: a test proves what it can see, and a mocked provider cannot see AWS. A plan is a forecast. An apply is a measurement.
What a mocked provider cannot see
The platform under test is a public reference platform for Amazon EKS: Terraform for the network, EKS and its add-ons, Argo CD for everything inside the cluster, Kyverno for admission, Karpenter for nodes, Velero for backups. Its Terraform tests run on every change against a mocked provider, and the whole platform runs on kind on every change and every day. The night of 15 October was the first time any of it met an AWS account. Each run reports its own length and estimates its cost from resource-hours at list prices, before data transfer, logs and load balancer capacity units.
| Run | Length | Estimate | What stopped it |
|---|---|---|---|
| 1 | 0.04 h | $0.00 | The role refused the run at the first AWS call |
| 2 | 0.02 h | $0.00 | A rule name past an AWS limit, and a guardrail that denied the run’s role its own iam:GetRole |
| 3 | 1.19 h | $0.31 | Argo CD’s root Application stuck in Unknown for a reason its log does not name, and a residue search that failed a clean teardown |
| 4 | 0.40 h | $0.08 | Anonymous chart pulls from ECR Public refused |
| 5 | 0.55 h | $0.12 | Argo CD started before the cluster’s metrics API answered |
| 6 | 0.59 h | $0.13 | The same symptom, traced to the metrics-server add-on |
| 7 | 1.72 h | $0.45 | 18 of 19 Applications healthy, then a load balancer outlived the cluster |
| 8 | 0.81 h | $0.20 | Passed every check and left nothing |
The seven failures cost a dollar and nine cents by the same estimates, more than five times the run that passed. What they printed is what an engineer pastes into a search box, so the post quotes it verbatim.
# the seven failed validation runs, 15 October 2026, verbatim, account ID removedNot authorized to perform sts:AssumeRoleWithWebIdentity"name" cannot be longer than 64 characters: "reference-platform-38666856654-karpenter-capacity-reservation-interruption"is not authorized to perform: iam:GetRole on resource: role platform-validation-create with an explicit deny in an identity-based policyresponse status code 429: toomanyrequests: Data limit exceedederror getting openapi resources: the server is currently unable to handle the requestDependencyViolation: The subnet 'subnet-0b2546018e28dfddd' has dependencies and cannot be deleted.Three of the seven came from outside the repository, and nothing in it had changed. GitHub moved repositories created, renamed or transferred after 15 July 2026 to an immutable OIDC subject that carries the owner’s and the repository’s IDs, so a trust policy written for the old owner-and-name format refused every run before it started. ECR Public gives an anonymous client 500 gigabytes a month and one pull a second, and a hosted runner fetching Karpenter’s chart without logging in was told the data limit was exceeded. And the default metrics-server add-on for EKS 1.37, in October 2026, is built with Go 1.27, where the new JSON implementation is the default.
That last one took two runs. The cluster’s OpenAPI document still answered as JSON, which is how a person checks it, and failed as protobuf, which is how Argo CD reads it. Argo CD could not build its cluster cache, and the root Application sat in Unknown behind an error about a server that could not handle the request. The upstream issue has been open since 1 September, and on 15 October I added the EKS builds and the pin that works to it. For three days the platform held the add-on on its last Go 1.26 build. On 16 October the add-on’s thirteenth EKS build was reported fixed on the same issue: the same Go 1.27, with the new JSON implementation switched off. The platform moved to it on 18 October, and two checks keep the move honest. Before it creates anything, the weekly run asks EKS whether that build is offered for the cluster’s Kubernetes version. Before it installs anything, the bootstrap asks for the document the way Argo CD does.
# bootstrap/bootstrap.sh in aws-eks-reference-platform at v1.3.0, shortened: read the cluster's OpenAPI document the way Argo CD reads it, as protobufproxy_dir="$(mktemp -d)"kubectl proxy --unix-socket="$proxy_dir/api.sock" >/dev/null 2>&1 &proxy=$!trap 'kill "$proxy" 2>/dev/null || true; rm -rf "$proxy_dir"' EXITcode=000for _ in $(seq 1 30); do kill -0 "$proxy" 2>/dev/null || { echo "kubectl proxy exited; cannot ask for the OpenAPI document" >&2; exit 1; } if [ -S "$proxy_dir/api.sock" ]; then code="$(curl -s --max-time 30 --unix-socket "$proxy_dir/api.sock" -o /dev/null -w '%{http_code}' \ [ "$code" = 200 ] && break fi sleep 10done# ... the proxy is stopped and its directory removedif [ "$code" != 200 ]; then echo "/openapi/v2 as protobuf answers HTTP $code; Argo CD cannot build its cluster cache on this cluster." >&2 # ... the upstream issue is named and the aggregated APIs are listed exit 1fiThe pin carries its own history, so the next person to lift it knows what it is guarding against.
# terraform/addons.tf in aws-eks-reference-platform at v1.3.0, shortened: the pin and the reason for it# metrics-server is pinned to v0.9.0-eksbuild.13. eksbuild.12, the default# for Kubernetes 1.37 in October 2026, is built with Go 1.27, whose default# jsonv2 experiment makes kube-openapi leak field names into the schema it# publishes; the API server then answers /openapi/v2 as protobuf with 503,# and Argo CD cannot build its cluster cache ("error getting openapi# resources: the server is currently unable to handle the request"). The# first EKS validation runs found it, and the platform held eksbuild.11, the# last build on Go 1.26. eksbuild.13 is Go 1.27 built with# GOEXPERIMENT=nojsonv2, the fix AWS reported on# https://github.com/kubernetes-sigs/metrics-server/issues/1868. Two checks# keep it honest: the weekly run asks EKS before applying anything whether# this build is offered for the cluster's Kubernetes version, and bootstrap# reads /openapi/v2 as protobuf, so a build that brings the 503 back fails# there.resource "aws_eks_addon" "after_nodes" { for_each = { coredns = { service_account = null, role_arn = null, version = null } metrics-server = { service_account = null, role_arn = null, version = "v0.9.0-eksbuild.13" } aws-ebs-csi-driver = { service_account = "ebs-csi-controller-sa", role_arn = aws_iam_role.ebs_csi.arn, version = null } } # ...The first scheduled run, on 21 October, put both checks to work on a real cluster. EKS offered the thirteenth build for Kubernetes 1.37, the document answered as protobuf, and every Application came up Synced and Healthy.
The other four were the platform’s own, and only the real API could judge them. An EventBridge rule name reached 74 characters once the cluster name carried the run’s ID, and AWS stops at 64. The guardrail that keeps workflow roles away from the bootstrap’s own IAM denied reads as well as writes, so Terraform could not look up the role it was running as. A residue search failed a teardown that had left nothing, because the tagging API kept listing a deleted NAT gateway, subnet, security group and flow log, and three keys that were only scheduled for deletion. And deleting Argo CD’s root Application cascaded through the load balancer controller before the Gateway’s Service was gone, so a network load balancer outlived the cluster and held the network open.
In the production environment I run, the first apply of anything new has always been where the account’s own rules showed up, never the plan.
Why this keeps happening
A mocked provider answers with what the test’s author believed about AWS on the day the test was written. It has no quotas, no name limits, no identity formats and no default add-on builds, and it never changes on its own. Everything around it does. Three of the seven failures were changes and limits outside the repository. A fourth trap sits in AWS’s own defaults: Budgets counts credits unless told otherwise, so in an account running on promotional credits a budget alert waits for the credits to run out.
The kind suite has the opposite blind spot. It runs the Kubernetes the platform deploys, every day, and catches what admission, GitOps and backups do inside a cluster. It has no cloud controllers, no load balancers made by AWS, no IAM and no add-ons that someone else builds. Each layer proved what it could reach. None of them reached the account.
A long-lived staging cluster looks like the answer and is a different test. It proves the platform keeps running. It does not prove the platform can be built from nothing, which is the case that matters on the day a region, an account or a customer’s environment has to be created. It is the gap behind the restore drill on a clean machine, one layer up, and its bill runs whether anyone reads it or not.
Proof that never meets the account is a forecast.
Risk and blast radius
Direct exposure is the first real apply happening when someone needs it: a rebuild after an incident, a new region, a customer’s account during onboarding. Every failure on this list would have arrived then, one at a time, with the clock running. For a solutions architect or a forward deployed engineer, that first apply happens in a customer’s account, in front of the customer. The night of 15 October found all seven in under five hours, for about a dollar.
Systemic exposure is the copy. A reference platform exists to be copied into other accounts, and every copy inherits the trust policy that no longer matches GitHub’s subject, the add-on build that blinds Argo CD and the teardown that leaves a load balancer billing. For a CISO, two of the seven sat in identity: a security improvement at GitHub that refuses every trust policy written the old way, and a guardrail strict enough to deny the role it protects. For a CTO or a finance lead, the credits default means a company living on credits can have budget alerts configured and still never receive one.
Three of the seven, and the credits default, are four questions any account can answer today, before anything is built.
# four questions for an account of your own. The first, the first half of the second and the fourth are adapted from# aws-eks-reference-platform at v1.3.0 (terraform/bootstrap-aws/variables.tf and .github/workflows/eks-validation.yml).# The third reads the setting that repository's budget turns off (terraform/bootstrap-aws/main.tf, include_credit = false).
# 1. The subject prefix GitHub writes into this repository's OIDC token, and what the role trusts. The role's sub must begin with it.gh api repos/OWNER/NAME/actions/oidc/customization/sub --jq .sub_claim_prefixaws iam get-role --role-name ROLE --query 'Role.AssumeRolePolicyDocument.Statement[].Condition' --output json
# 2. The metrics-server builds EKS offers for a Kubernetes version (here 1.37), and the build a cluster runs.aws eks describe-addon-versions --kubernetes-version 1.37 --addon-name metrics-server \ --query 'addons[0].addonVersions[].addonVersion' --output textaws eks describe-addon --cluster-name CLUSTER --addon-name metrics-server --query addon.addonVersion --output text
# 3. Whether each budget counts credits. True means the budget waits for the credits to run out.aws budgets describe-budgets --account-id "$(aws sts get-caller-identity --query Account --output text)" \ --query 'Budgets[].[BudgetName,CostTypes.IncludeCredit]' --output text
# 4. Log in to ECR Public before pulling a chart from it, so the pull is the account's and not an anonymous one.aws ecr-public get-login-password --region us-east-1 | helm registry login --username AWS --password-stdin public.ecr.awsWays to prove an EKS platform, compared
| Way to prove it | What it costs | What it catches | What it cannot catch |
|---|---|---|---|
terraform test with a mocked provider, on every change | nothing in AWS, minutes of runner time | posture and configuration drift, on every push | limits, identity formats, defaults and add-on builds that AWS and others decide |
| e2e on kind, daily | runner minutes | admission, GitOps sync, backups, the platform’s own behaviour | cloud controllers, AWS load balancers, IAM, managed add-ons |
| a real account, weekly, the network, cluster and platform destroyed after each run | about 1 a month | first-apply failures, teardown residue, changes outside the repository | scale, load, drift over months |
| a long-lived staging cluster | about $235 a month before workloads, at the list prices in the platform’s README | drift over time, upgrades in place | a clean build from nothing, a clean teardown |
The table is not a ranking. The first two run on every change because they are free and fast, and they stay. The third answers the one question the first two cannot, for less than a dollar a month.
None of the four is new: ephemeral environments and Terratest have applied real infrastructure from CI for years. What this night adds is a price and a list.
Framework: a ceiling, a clean teardown, a weekly run
Layer 1: an account that can only spend up to a ceiling (week 1)
Give the runs a member account of their own with nothing else in it, because the workflow roles can delete what they did not make. Put the ceiling in AWS Budgets, measured before credits, with an action that attaches a deny-all policy to the role that creates resources.
# kill-switch.tf: a standalone version for an account of your own, adapted from terraform/bootstrap-aws/main.tf in# aws-eks-reference-platform, where the limits are variables ($10, $20 and $30 by default) and the role is the module's own.# Alerts on usage before credits, then deny-all at the ceiling.variable "creation_role_name" { type = string}
variable "alert_email" { type = string}
data "aws_caller_identity" "current" {}
resource "aws_budgets_budget" "validation" { name = "platform-validation-monthly" budget_type = "COST" limit_amount = "30.00" limit_unit = "USD" time_unit = "MONTHLY"
cost_types { include_credit = false # Budgets counts credits by default: on promotional credits nothing would ever fire. }
dynamic "notification" { for_each = [10, 20] content { comparison_operator = "GREATER_THAN" threshold = notification.value threshold_type = "ABSOLUTE_VALUE" notification_type = "ACTUAL" subscriber_email_addresses = [var.alert_email] } }}
resource "aws_iam_policy" "deny_all" { name = "platform-validation-deny-all" policy = jsonencode({ Version = "2012-10-17", Statement = [{ Effect = "Deny", Action = "*", Resource = "*" }] })}
resource "aws_iam_role" "budget_action" { name = "platform-validation-budget-action" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "sts:AssumeRole" Principal = { Service = "budgets.amazonaws.com" } Condition = { StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id } } }] })}
resource "aws_iam_role_policy" "budget_action" { name = "attach-deny-all-to-the-creation-role" role = aws_iam_role.budget_action.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["iam:AttachRolePolicy", "iam:DetachRolePolicy"] Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.creation_role_name}" Condition = { ArnEquals = { "iam:PolicyARN" = aws_iam_policy.deny_all.arn } } }] })}
# At the ceiling, Budgets attaches deny-all to the creation role. The sweeper's role is not touched.resource "aws_budgets_budget_action" "kill_switch" { budget_name = aws_budgets_budget.validation.name action_type = "APPLY_IAM_POLICY" approval_model = "AUTOMATIC" notification_type = "ACTUAL" execution_role_arn = aws_iam_role.budget_action.arn
action_threshold { action_threshold_type = "ABSOLUTE_VALUE" action_threshold_value = 30 }
definition { iam_action_definition { policy_arn = aws_iam_policy.deny_all.arn roles = [var.creation_role_name] } }
subscriber { address = var.alert_email subscription_type = "EMAIL" }}The sweeper that cleans up behind the runs uses a second, delete-only role, which the switch does not touch, so cleaning goes on after creation has stopped. The switch is a ceiling for the month, not a brake on a run: AWS refreshes the billing data Budgets reads at least once a day, and a deny on the role stops new resources, not the ones already running. The brakes on a single run are its own timeout, the destroy job that runs whatever happened to the run, and the daily sweeper. Alerts at ten and twenty dollars and the switch at thirty make the ceiling a month of weekly runs with room for a bad night.
Owner: the platform team, with the alert address agreed with whoever owns the bill.
Layer 2: a teardown that proves it left nothing (week 2)
Order decides whether a teardown finishes. Stop the GitOps controller first, by scaling it to zero rather than deleting its Applications, or the deletion cascades through the platform and takes the load balancer controller with it. Delete what makes AWS resources while the controllers that made them still run: Gateways, the remaining load balancer Services, Ingresses, Karpenter’s node pools and node claims, the volume claims. Then delete from AWS’s side, by the controllers’ tags, whatever they left, and only then let Terraform take the network. Then prove the account is clean, and do not trust the tagging API’s first answer.
# residue.sh: a standalone version for one run ID, adapted from scripts/aws-residue.sh in aws-eks-reference-platform,# not that file's text. The tagging API lists what was just deleted, so ask each ARNset -uo pipefailrun_id="${1:?run-id}"left=0
exists() { # exists <arn>: 0 while the resource is there and not on its way out local arn="$1" id="${1##*/}" case "$arn" in *:natgateway/*) aws ec2 describe-nat-gateways --nat-gateway-ids "$id" \ --query 'NatGateways[].State' --output text 2>/dev/null | grep -qvE '^(deleted)?$' ;; *:subnet/*) aws ec2 describe-subnets --subnet-ids "$id" >/dev/null 2>&1 ;; *:security-group/*) aws ec2 describe-security-groups --group-ids "$id" >/dev/null 2>&1 ;; *:vpc-flow-log/*) aws ec2 describe-flow-logs --flow-log-ids "$id" \ --query 'FlowLogs[].FlowLogId' --output text 2>/dev/null | grep -q . ;; *:volume/*) aws ec2 describe-volumes --volume-ids "$id" >/dev/null 2>&1 ;; *:key/*) aws kms describe-key --key-id "$id" --query 'KeyMetadata.KeyState' \ --output text 2>/dev/null | grep -qvE '^(PendingDeletion|PendingReplicaDeletion)?$' ;; *) return 0 ;; # a type this script cannot ask about counts as left, never as gone esac}
for arn in $(aws resourcegroupstaggingapi get-resources --tag-filters "Key=run-id,Values=${run_id}" \ --query 'ResourceTagMappingList[].ResourceARN' --output text); do if exists "$arn"; then echo "left behind: $arn" left=1 fidone
[ "$left" -eq 0 ] && echo "nothing left from run ${run_id}"exit "$left"The platform’s own version also searches what the cluster’s controllers made outside Terraform, by the cluster’s tags: instances, volumes, network interfaces, security groups and load balancers.
Owner: platform engineer.
Layer 3: the run on a schedule, reconciled with the bill (week 3 onward)
A run nobody schedules is a run nobody makes. This one runs every Wednesday, after Monday’s dependency updates have merged, and by hand only when someone types the word that says they mean to spend. A repository variable stops the schedule without a commit. The first scheduled run, on 21 October, passed fourteen of fourteen checks against AWS in 0.62 hours, for about fourteen cents by the teardown’s own list-price estimate. The runs of 28 October and 4 November passed the same fourteen, at about sixteen cents each by the same estimate. GitHub started each of them between nearly five and more than six hours after its cron time, still on the Wednesday, which is how scheduled workflows behave under load, so nothing downstream should expect it on the minute.
# .github/workflows/eks-validation.yml in aws-eks-reference-platform at v1.3.0, shortened: weekly, by hand only with a typed word, stoppable without a commiton: schedule: # Wednesday: after Monday's Dependabot updates have merged, so the week's # pins reach AWS in days, not a week later. - cron: "17 1 * * 3" workflow_dispatch: inputs: confirm: description: "Type SPEND to create billed AWS resources (about $0.20 a passing run, up to about $0.50)" required: true type: string# ...jobs: up: name: Apply, install, check if: github.ref == 'refs/heads/main' && (inputs.confirm == 'SPEND' || (github.event_name == 'schedule' && vars.AWS_VALIDATION_ENABLED == 'true')) runs-on: ubuntu-latest environment: aws-validation timeout-minutes: 100 # ... apply, install and check; a second job destroys, searches for residue and estimates the costThe run’s own estimate is a forecast too. The daily sweeper reads yesterday’s bill from Cost Explorer, before credits and refunds, so the estimate and the bill sit side by side.
# .github/workflows/eks-sweeper.yml in aws-eks-reference-platform at v1.3.0, shortened: what the account used yesterday, by service, before credits and refundsstart="$(date -u -d yesterday +%F)"; end="$(date -u +%F)"# ... a table header for the run's summaryaws ce get-cost-and-usage --time-period "Start=$start,End=$end" --granularity DAILY \ --metrics UnblendedCost --group-by Type=DIMENSION,Key=SERVICE \ --filter '{"Not":{"Dimensions":{"Key":"RECORD_TYPE","Values":["Credit","Refund"]}}}' \ --query 'ResultsByTime[0].Groups[].[Keys[0],Metrics.UnblendedCost.Amount]' --output text | awk -F'\t' '$2 > 0.005 {printf "| %s | $%.2f |\n", $1, $2; t += $2} END {printf "| **Total** | **$%.2f** |\n", t}'Owner: SRE, with a failed weekly run treated as a finding, not a flaky job.
Tradeoffs
A passing run takes under an hour of runner time across its two jobs and costs between fourteen and twenty cents in AWS by its own list-price estimate, so a weekly run costs under a dollar a month. Failures cost more than success: the seven failed runs cost more than five times the one that passed, and the most expensive of them was the one that got furthest. The account has to exist inside an AWS Organization and hold nothing else. And the run proves a build from nothing with two system nodes, demo tenants and a Let’s Encrypt staging certificate. It says nothing about scale or load, and the platform’s README says so in its known limitations.
Skipping it costs nothing on every day before the first real apply. On that day the seven failures arrive in someone else’s account.
The closing argument
Mocked tests and a kind suite are the right defaults, and they keep running on every change. They prove the platform agrees with itself. Only an apply proves it agrees with AWS, and GitHub and EKS moved the ground under this one twice since July without a commit in the repository. Twenty cents a week buys the answer before anyone needs it.
Sources
- heyvaldemar, the EKS reference platform, the October release (its changelog carries every finding) and the passing validation run, 15 October 2026
- GitHub Docs, OpenID Connect reference, immutable subject claims (repositories created, renamed or transferred after 15 July 2026), accessed November 2026
- Kubernetes SIGs, metrics-server issue on a broken OpenAPI document, opened 1 September 2026
- heyvaldemar, my comment there, with the EKS builds and the working pin, 15 October 2026, and the fixed build reported, 16 October 2026
- heyvaldemar, the move to the fixed build, 18 October 2026
- Go, Go 1.27 release notes (the new JSON implementation is the default), accessed November 2026
- AWS, Budgets cost types (credits included by default) and budget actions, accessed November 2026
- AWS, EventBridge PutRule reference (a rule name of at most 64 characters), accessed November 2026
- AWS, Amazon ECR Public service quotas (500 gigabytes a month and one pull a second without authentication), accessed November 2026
- AWS, Amazon EKS pricing (the control plane per hour, standard and extended support), accessed November 2026
- AWS Builder Center, Testing Terraform for AWS With No AWS Account, 3 October 2026
Discussion
If you prove infrastructure on a real account on a schedule, or you have a reason to trust the plan alone, drop a comment below. Counterarguments are welcome and the comment thread is where I respond first. For longer back-and-forth with senior practitioners, join the discussion on Discord.