Terraform Lesson 69 of 89

Terraform on AWS EKS: Assembling a Production Platform — the End-to-End Capstone

Every EKS lesson in this track taught one thing in isolation — how to provision the cluster, how to wire the Kubernetes and Helm providers, how to autoscale with Karpenter, how to isolate environments. Each was a demo you could run. This lesson is the moment they stop being demos and become a platform: one coherent, production-shaped estate where the pieces are composed in the right order, in the right state boundaries, with the security and reliability that lets an on-call engineer sleep. It is the capstone, and its real subject is not any single resource but the layering and repository structure that keeps thirty-odd moving parts maintainable when three teams are changing them at once.

There is a failure mode this lesson exists to prevent, and it is worth naming up front because you will recognise it. A team stands up EKS with the community module, feels the rush of kubectl get nodes returning, and then — in the same Terraform configuration — bolts on the Load Balancer Controller, ExternalDNS, the CSI drivers, Prometheus and Argo CD with a pile of helm_release blocks. It works on the first apply on a good day. Then the day comes when the cluster must be recreated, or the endpoint changes, or someone runs destroy, and the whole thing detonates: the Helm provider can’t reach a cluster that doesn’t exist yet, a helm_release refuses to plan because its provider configuration is “unknown”, and the state is a tangle where a routine add-on bump proposes to touch the VPC. Nothing in the HCL was wrong. What was missing was structure — separate state per layer, a deliberate apply order, IRSA instead of node permissions, and a clean seam between what Terraform owns and what GitOps owns.

You already have the parts. Here we assemble the reference production EKS platform from them and, just as importantly, lay out the live/{env}/{network,cluster,platform,apps} repository that makes it survivable. Read the prose once for the reasoning; the tables — the platform-capability catalogue, the state-layering map, the apply-order and upgrade-order tables, the per-environment sizing grid, the SRE/security-controls catalogue, and the operations troubleshooting map — are the reference you keep open while you build. By the end you will be able to stand up an EKS platform that an auditor, an SRE and a new hire can all read, that upgrades without drama, and whose blast radius is designed into the state keys.

What you’ll build

The running example is Kestrel, a mid-size SaaS company standing up its first governed production platform on EKS in ap-south-1 (Mumbai) to run a customer-facing product plus a fleet of internal services. Kestrel’s platform team owns the cluster and the add-ons; product teams own the apps that run on top. We build that split as four state layers, applied left to right, each with its own remote state:

The architecture in words, read as a request travels it: a user hits app.kestrel.example, Route 53 resolves it — the record was created by ExternalDNS watching an Ingress — to an Application Load Balancer that the AWS Load Balancer Controller provisioned from that same Ingress. The ALB forwards to pods on Karpenter-provisioned worker nodes (mostly spot, right-sized, consolidated when idle) in the private subnets. Those pods reach AWS APIs — S3, Secrets Manager, Route 53 — not through a node role or a static key but through IRSA: each service account assumes its own narrowly-scoped IAM role via the cluster’s OIDC provider. Secrets arrive through the External Secrets Operator pulling from Secrets Manager; storage is EBS (gp3) and EFS through the CSI drivers; metrics flow to Prometheus/CloudWatch; and the whole platform is upgraded control-plane-first on a schedule and backed up by Velero. That is the shape, and it is the same shape at three nodes or three hundred.

Why Terraform for all of this rather than eksctl, a stack of kubectl apply, or clicking through the console? Because a platform’s whole value is that it is reproducible, reviewable and enforced. eksctl is excellent for a quick cluster but stops at the cluster edge and keeps no desired-state model of the add-ons or the IAM. A pile of kubectl and aws commands is a script with no notion of drift. The console produces snowflakes nobody can recreate. Terraform’s provider model lets the same tool and the same state discipline govern the AWS resources (VPC, EKS, IAM, KMS), the in-cluster resources (via the kubernetes and helm providers), and the DNS zone together — which is exactly what a platform team needs. And Terraform’s module + remote-state model is the cleanest expression of the two things a platform must have: reuse (one module, many environments) and isolation (one state per layer per environment).

Here is the leap this lesson is about — the difference between “a cluster with some Helm on it” and a platform, stated as symptoms you can recognise on your own estate:

Dimension A cluster with add-ons bolted on The platform you build here
State One config for cluster + all add-ons Separate state per layer: network / cluster / platform / apps
Provider ordering helm provider points at a cluster in the same apply Add-ons read an already-created cluster via a data source
Workload identity Node role holds every permission IRSA per controller; the node role holds almost nothing
Secrets Static keys in a Kubernetes Secret External Secrets Operator + IRSA to Secrets Manager
Scaling Fixed on-demand node group Small system NG + Karpenter spot with consolidation
App delivery helm_release per app in Terraform Terraform installs Argo CD; Argo CD owns app sync
Upgrades “bump the version and hope” Control plane → nodes → add-ons, with skew rules
Blast radius One destroy takes the estate One layer’s failure costs exactly that layer

By the end you can build every row of the right-hand column with real HCL.

Learning objectives

By the end of this lesson you will be able to:

Prerequisites & where this fits

This is the EKS capstone of the Terraform Zero-to-Hero course. It assumes the EKS building blocks are already familiar and pulls them into one estate. You will get the most from it having already provisioned an EKS cluster with its VPC and node groups, wired the Kubernetes and Helm providers to deploy apps and GitOps, set up the Cluster Autoscaler and Karpenter, driven a multi-environment estate with Terragrunt and approval gates, and built the AWS 3-tier platform with reusable modules, SRE and remote state. Where those lessons each teach one plane, this one shows all of them driven by a single production EKS platform at once.

A note on versions: everything targets Terraform ≥ 1.6 (the 1.9/1.10 line current in 2026) with the aws provider ~> 5.0, the kubernetes provider ~> 2.35, the helm provider ~> 2.17, the terraform-aws-modules/eks/aws module ~> 20.0 (the v20 line uses access entries and drops aws-auth by default), and the terraform-aws-modules/vpc/aws module ~> 5.8. OpenTofu is a drop-in for the CLI throughout; the module and state model are identical. Assume you have aws credentials working (SSO or an assumed role) in an account where you can create a VPC, an EKS cluster, IAM roles and KMS keys, and that kubectl and the aws CLI (for aws eks get-token) are on your PATH.

Because this platform pins those providers and uses the S3 backend, the handful of choices that bite an upgrader or a first-timer are worth having in front of you — every one is reflected in the HCL below:

Choice / gotcha Why it matters What to do
helm provider version v3 (2025) changed provider config to a top-level kubernetes = {} attribute This lesson pins ~> 2.17 (nested kubernetes {} block); note the v3 change before bumping
exec auth, not a static token aws_eks_cluster_auth bakes a short-lived token into state Use the provider exec block calling aws eks get-token
EKS module ~> 20.0 v20 replaced aws-auth with access entries and changed inputs Use authentication_mode + aws_eks_access_entry; read the v20 upgrade guide
One state per layer The helm provider can’t point at a cluster made in the same apply Split cluster and platform states; platform reads the cluster
IRSA over node-role perms A permission on the node role is granted to every pod One IRSA role per add-on; the node role stays minimal

Here is the map of companion lessons and what each carries, so you know where to go deep on any one plane:

You want to go deep on… Companion lesson What it adds beyond this capstone
The cluster + VPC + node groups EKS cluster provisioning Endpoint modes, KMS, log types, access entries, managed NG internals
K8s/Helm providers + GitOps Kubernetes & Helm providers, app deployment & GitOps Provider chaining, helm_release internals, the split-apply rule
Autoscaling worker nodes Cluster Autoscaler & Karpenter NodePools, EC2NodeClass, consolidation, spot interruption
Multi-env with gates Multi-environment with Terragrunt dependency, mocks, run-all, approval gates
Modules, remote state, SRE AWS 3-tier architecture & remote state Module contracts, terraform_remote_state, default_tags, budgets

The reference EKS platform: the “50-demo” synthesis

The signature of this capstone is that it is a synthesis: nearly every capability you built in a standalone lesson has a home in this one platform, delivered by a specific module or Helm chart, in a specific layer. Before any HCL, here is the catalogue — the roughly three dozen platform capabilities, which prior lesson taught each, and the module or chart that delivers it. This is the table you scan to answer “where does that live?” and it is the real table of contents for the build:

# Capability Layer Taught in Delivered by (module / chart)
1 Tagged multi-AZ VPC (public/private/intra) network EKS cluster provisioning terraform-aws-modules/vpc/aws
2 Subnet discovery tags (ELB, internal-ELB, Karpenter) network EKS cluster provisioning tags on the VPC subnets
3 NAT egress + private routing network AWS VPC lesson vpc module (single_nat_gateway per env)
4 EKS control plane, private API endpoint cluster EKS cluster provisioning terraform-aws-modules/eks/aws
5 KMS envelope encryption for Secrets cluster EKS cluster provisioning eks module cluster_encryption_config
6 Control-plane audit + component logs cluster EKS cluster provisioning cluster_enabled_log_types
7 Access entries (no aws-auth) cluster EKS cluster provisioning aws_eks_access_entry (+ policy assoc)
8 Managed system node group cluster EKS cluster provisioning eks module eks_managed_node_groups
9 OIDC provider for IRSA cluster EKS cluster provisioning eks module (oidc_provider_arn)
10 Karpenter IAM (node role, queue, controller) cluster Autoscaler & Karpenter terraform-aws-modules/eks/aws//modules/karpenter
11 Karpenter Helm + NodePool/EC2NodeClass platform Autoscaler & Karpenter oci://public.ecr.aws/karpenter chart
12 Cluster Autoscaler (alternative) platform Autoscaler & Karpenter cluster-autoscaler chart + IRSA
13 AWS Load Balancer Controller platform K8s/Helm & GitOps aws-load-balancer-controller chart + IRSA
14 ExternalDNS → Route 53 platform K8s/Helm & GitOps external-dns chart + IRSA
15 metrics-server (HPA source) platform K8s/Helm & GitOps metrics-server chart
16 EBS CSI driver platform K8s/Helm & GitOps aws-ebs-csi-driver addon + IRSA
17 EFS CSI driver platform K8s/Helm & GitOps aws-efs-csi-driver chart + IRSA
18 CoreDNS / kube-proxy / VPC CNI cluster EKS cluster provisioning EKS managed add-ons (aws_eks_addon)
19 CloudWatch Observability / Container Insights platform CloudWatch monitoring lesson amazon-cloudwatch-observability addon + IRSA
20 kube-prometheus-stack (metrics + alerts) platform K8s/Helm & GitOps kube-prometheus-stack chart
21 External Secrets Operator platform Secrets-in-IaC lesson external-secrets chart + IRSA
22 cert-manager (in-cluster TLS) platform K8s/Helm & GitOps cert-manager chart
23 Argo CD (GitOps engine) platform K8s/Helm & GitOps argo-cd chart
24 App-of-apps delivery apps K8s/Helm & GitOps Argo CD Application (from Git)
25 Velero backup / DR platform this lesson velero chart + IRSA
26 Remote state per layer (S3 + lock) all AWS 3-tier & remote state backend "s3" + DynamoDB / use_lockfile
27 Cross-layer reads all AWS 3-tier & remote state data "terraform_remote_state"
28 DRY multi-env wiring all Multi-env with Terragrunt Terragrunt dependency blocks
29 Promotion dev→staging→prod all Multi-env with Terragrunt per-env tfvars / inputs
30 default_tags on every AWS resource all AWS 3-tier & remote state provider "aws" { default_tags {} }
31 Least-privilege IRSA (no node perms) platform AWS IAM lesson iam-role-for-service-accounts-eks
32 Pod Security Standards apps this lesson namespace pod-security.kubernetes.io/* labels
33 Network policies apps this lesson kubernetes_manifest NetworkPolicy / CNI
34 Budgets + cost alerts all AWS 3-tier & remote state aws_budgets_budget
35 CI/CD OIDC pipeline gate all GitHub Actions OIDC lesson GitHub OIDC role + plan-on-PR
36 Policy scanning (checkov/tfsec) all IaC scanning lesson CI required status check
37 Cluster upgrades in order cluster/platform this lesson cluster_version bump + node roll

That is the “50-demo” made concrete: no capability is invented here; each is a lesson you already ran, now placed in a layer and wired to the rest. The single most important column is Layer, because the layer is the state boundary, and the state boundary is what makes the platform maintainable.

Left-to-right production EKS platform built by Terraform: four state layers — network, cluster, platform and apps — build a tagged multi-AZ VPC, then an EKS cluster with a private API endpoint, KMS envelope encryption and control-plane logs plus a small managed system node group and Karpenter for spot workloads, then the platform add-ons layer where the AWS Load Balancer Controller, ExternalDNS, the CSI drivers, observability and the External Secrets Operator each run under their own IRSA role delivered by Helm, then Argo CD syncing the application workloads with Route 53 records managed by ExternalDNS

The diagram traces the whole platform left to right: the four Terraform/Terragrunt state layers on the left provision, in dependency order, the tagged VPC, the EKS cluster with its system nodes and Karpenter, the platform add-ons (every one under an IRSA role, shipped by Helm, observed by Prometheus/CloudWatch), and finally Argo CD syncing the apps with Route 53 records from ExternalDNS. The six badges are the six load-bearing decisions of the lesson: state per layer, IRSA everywhere, Karpenter + spot, the separate platform add-ons layer (the provider-ordering fix), GitOps for apps, and the fixed upgrade order.

The request path and the identity path are the two mental models to hold. The request path is how traffic reaches a pod; the identity path is how a pod reaches AWS — and the second is the one juniors skip, then spend a day debugging an AccessDenied:

# Request path hop Component Notes
1 User → Route 53 → ALB ExternalDNS + LB Controller Record and ALB both provisioned from the Ingress
2 ALB → pod LB Controller, target-type: ip Registers pod IPs directly (no node-port double hop)
3 Pod → pod (east-west) VPC CNI + NetworkPolicy Default-deny where policy is enforced
4 Pod → AWS API IRSA (OIDC) SA assumes its own role; not the node role
5 Pod → Secret External Secrets Operator Pulls from Secrets Manager via its IRSA role

State layering: network → cluster → platform → apps

The repository is the architecture, and on EKS the layering is not a nicety — it is forced on you by a hard technical constraint, then justified three more ways. Get the layers right and the platform is maintainable; get them wrong and you meet the detonation from the opening.

The provider-ordering problem — the reason the layers are not optional. Terraform resolves provider configuration at plan time, before most resources exist. The kubernetes and helm providers need the cluster’s API endpoint and CA to talk to it. If you configure those providers from module.eks.cluster_endpoint and create helm_release resources in the same configuration, you have asked a provider to depend on a resource being created in its own apply. On a clean first apply this is “unknown at plan time”; on a rebuild or a destroy it fails outright, because the provider can’t reach a cluster that is gone or not yet there. The robust fix is structural: the cluster layer creates the cluster and outputs its identity; the platform layer reads an already-created cluster via data "aws_eks_cluster" and only then configures the kubernetes/helm providers. A provider must point at infrastructure that already exists in another state — that single rule is why cluster and platform are separate layers.

The other three justifications reinforce it:

Why split The argument
Provider ordering A kubernetes/helm provider can’t be configured from a cluster made in the same apply — the platform layer must read an existing cluster
Blast radius The platform layer churns weekly; network changes quarterly. Separate state means an add-on bump can never plan a VPC change or delete the cluster
Team ownership Networking owns network, the platform team owns cluster+platform, product teams own apps — separate states let each apply on its own cadence with its own CI role
Apply/destroy time A tight platform state plans in seconds; folding it into the cluster state makes every add-on tweak re-evaluate the whole cluster

Here is the canonical layout for the Kestrel platform. Read it by state boundaries, because that is what matters at 2 a.m. — every leaf under live/ is a root module with its own state key, and that key is the unit of plan, apply and blast radius:

kestrel-platform/
├── modules/                         # the library — reusable, versioned, never applied directly
│   ├── eks-platform-addons/         # composes IRSA + helm for all add-ons (the star of this lesson)
│   │   ├── main.tf                  # for_each over add-ons: irsa role + helm_release
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   └── README.md
│   └── karpenter-nodepool/          # EC2NodeClass + NodePool manifests
│
├── live/                            # per-env ROOT configs — each LAYER has its own state key
│   ├── dev/
│   │   ├── network/                 # key: dev/network/terraform.tfstate   (vpc)
│   │   ├── cluster/                 # key: dev/cluster/terraform.tfstate    (eks + system NG + karpenter IAM)
│   │   ├── platform/                # key: dev/platform/terraform.tfstate   (IRSA + helm add-ons + argocd)
│   │   └── apps/                    # key: dev/apps/terraform.tfstate       (argocd Application → Git)
│   ├── staging/                     # identical structure, staging inputs
│   └── prod/
│       ├── network/
│       ├── cluster/
│       │   ├── versions.tf          # required_providers + backend "s3" {}
│       │   ├── main.tf              # module "eks" + module "karpenter" + access entries
│       │   ├── variables.tf
│       │   ├── outputs.tf           # cluster_name, endpoint, oidc_provider_arn, karpenter_*
│       │   ├── prod.auto.tfvars
│       │   └── backend.hcl          # key = "prod/cluster/terraform.tfstate"
│       ├── platform/
│       │   ├── versions.tf          # aws + kubernetes + helm providers
│       │   ├── main.tf              # data.terraform_remote_state.cluster → providers → add-ons
│       │   └── backend.hcl          # key = "prod/platform/terraform.tfstate"
│       └── apps/
│
├── gitops/                          # the manifests repo Argo CD syncs (apps live HERE, not in Terraform)
├── tests/                           # native terraform test (*.tftest.hcl)
├── .tflint.hcl
└── .checkov.yaml

The one rule that saves you: never terraform apply inside modules/ — modules are consumed by source, not run. And the second: apps live in gitops/, not in Terraform — Terraform installs Argo CD; Argo CD syncs the app manifests. The state-layering table is the reference:

Layer Owns Reads (via remote state) Own state key Changes Team
network VPC, subnets, NAT, subnet tags <env>/network/…tfstate Quarterly Networking
cluster EKS, system NG, KMS, OIDC, Karpenter IAM network <env>/cluster/…tfstate On upgrades Platform
platform IRSA roles + Helm add-ons + Argo CD cluster (+ network) <env>/platform/…tfstate Weekly Platform
apps Argo CD Application(s) → Git platform <env>/apps/…tfstate Daily (via Git) Product

Wiring the layers. The lower layer reads the upper layer’s outputs. In native Terraform that is a terraform_remote_state data source; in Terragrunt it is a first-class dependency block with mock_outputs so a plan runs before the dependency exists. The cross-layer wiring map:

Producer layer → output Consumer layer ← input Why
network.vpc_id, private_subnets cluster Cluster and node group placement
cluster.cluster_name, cluster_endpoint, cluster_ca platform Configure the kubernetes/helm providers
cluster.oidc_provider_arn platform Trust anchor for every IRSA role
cluster.karpenter_queue_name, karpenter_irsa_arn, node_iam_role platform Karpenter Helm values + NodePool
platform.argocd_server, argocd_namespace apps Where the app-of-apps registers

The apply order is fixed — and its reverse is the destroy order. You cannot apply platform before cluster (no cluster to talk to) or cluster before network (no VPC to place it in). ⚠️ Getting the destroy order wrong is worse than getting apply wrong: destroy network before platform and you strand ENIs, load balancers and security groups that the add-ons created, and the VPC delete hangs for an hour.

Step Apply order (→) Destroy order (←) Gate
1 network apps PR review
2 cluster platform Platform approval
3 platform cluster Platform approval
4 apps (Argo CD takes over) network Product / manual

In Terragrunt, terragrunt run-all apply walks this graph for you from the dependency edges; in native Terraform you apply the folders in order (a thin wrapper script, or the CI pipeline, enforces it). Either way, the order is a property of the dependency graph, not a thing you remember — which is the whole point of encoding it in state layers.

The cluster layer: VPC + EKS + system nodes + Karpenter IAM

The cluster layer reads the network layer and stands up the control plane, the small system node group, and Karpenter’s cluster-scoped IAM. It is deliberately thin on Helm — nothing in this layer uses the kubernetes or helm provider, precisely so that the cluster can always be created and destroyed cleanly.

Start with the provider and the read of the network layer:

# live/prod/cluster/versions.tf
terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {}   # completed at init: key = prod/cluster/terraform.tfstate
}

provider "aws" {
  region = var.region
  default_tags { tags = local.common_tags }   # every AWS resource inherits these
}

data "terraform_remote_state" "network" {
  backend = "s3"
  config  = { bucket = "kestrel-tfstate-apsouth1", key = "prod/network/terraform.tfstate", region = "ap-south-1" }
}

Now the EKS cluster itself, via the community module — private endpoint, KMS envelope encryption, control-plane logs, access entries, and a small managed node group tainted for system add-ons only:

# live/prod/cluster/main.tf
locals { cluster_name = "eks-${var.workload}-${var.environment}" }   # eks-kestrel-prod

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.0"

  cluster_name    = local.cluster_name
  cluster_version = var.cluster_version                 # e.g. "1.30" — bumped on upgrades

  # Private API endpoint; public reachable only from office/CI CIDRs (tighten to false in prod-locked)
  cluster_endpoint_private_access      = true
  cluster_endpoint_public_access       = var.endpoint_public_access
  cluster_endpoint_public_access_cidrs = var.api_allowed_cidrs

  # Control-plane audit + component logs to CloudWatch
  cluster_enabled_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]

  # KMS envelope encryption for Kubernetes Secrets (encryption at rest, above EBS-level)
  create_kms_key            = true
  cluster_encryption_config = { resources = ["secrets"] }

  # Access entries API — no aws-auth ConfigMap surgery
  authentication_mode                      = "API_AND_CONFIG_MAP"
  enable_cluster_creator_admin_permissions = true

  # Core add-ons managed by EKS (kept in the CLUSTER layer, before any workload)
  cluster_addons = {
    coredns                = { most_recent = true }
    kube-proxy             = { most_recent = true }
    vpc-cni                = { most_recent = true }
    eks-pod-identity-agent = { most_recent = true }
  }

  vpc_id     = data.terraform_remote_state.network.outputs.vpc_id
  subnet_ids = data.terraform_remote_state.network.outputs.private_subnets

  # A SMALL managed node group for system add-ons only; workloads come from Karpenter
  eks_managed_node_groups = {
    system = {
      instance_types = var.system_instance_types      # ["m6i.large"]
      capacity_type  = "ON_DEMAND"                     # system add-ons on stable capacity
      min_size       = 2
      max_size       = 3
      desired_size   = 2
      labels = { role = "system" }
      taints = {
        addons = { key = "CriticalAddonsOnly", value = "true", effect = "NO_SCHEDULE" }
      }
    }
  }

  tags = local.common_tags
}

Two design decisions here are worth their own sentence. Why a managed system node group and Karpenter? Karpenter itself, CoreDNS and the CSI drivers need somewhere to run before Karpenter can provision anything — a chicken-and-egg. So a tiny, boring, on-demand managed group runs the system add-ons (tainted CriticalAddonsOnly so app pods don’t land on it), and Karpenter provisions everything else. Why access entries? Because the old aws-auth ConfigMap was an un-versioned, easy-to-lock-yourself-out-of blob; authentication_mode + aws_eks_access_entry makes cluster access a first-class, Terraform-managed IAM object.

An access entry granting a platform-admin SSO role cluster-admin, the modern replacement for an aws-auth line:

resource "aws_eks_access_entry" "platform_admins" {
  cluster_name  = module.eks.cluster_name
  principal_arn = var.platform_admin_role_arn      # e.g. an SSO permission-set role
  type          = "STANDARD"
}

resource "aws_eks_access_policy_association" "platform_admins" {
  cluster_name  = module.eks.cluster_name
  principal_arn = var.platform_admin_role_arn
  policy_arn    = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
  access_scope { type = "cluster" }
}

Karpenter’s cluster-scoped IAM — the node role, instance profile, interruption SQS queue and controller IRSA policy — belongs in the cluster layer (it is IAM, not Helm). The community submodule builds it in one block; its outputs feed the platform layer’s Helm install:

module "karpenter" {
  source  = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "~> 20.0"

  cluster_name          = module.eks.cluster_name
  enable_v1_permissions = true
  namespace             = "kube-system"
  node_iam_role_name    = "karpenter-node-${local.cluster_name}"

  # Let the controller assume its role via Pod Identity (or set up IRSA — either works)
  create_pod_identity_association = true
  tags                            = local.common_tags
}

The cluster layer’s outputs are the contract the platform layer consumes. Emit exactly what the providers, the IRSA trust and Karpenter need:

# live/prod/cluster/outputs.tf
output "cluster_name"      { value = module.eks.cluster_name }
output "cluster_endpoint"  { value = module.eks.cluster_endpoint }
output "cluster_ca"        { value = module.eks.cluster_certificate_authority_data }
output "cluster_version"   { value = module.eks.cluster_version }
output "oidc_provider_arn" { value = module.eks.oidc_provider_arn }   # ← IRSA trust anchor
output "node_security_group_id" { value = module.eks.node_security_group_id }

output "karpenter_queue_name"   { value = module.karpenter.queue_name }
output "karpenter_node_role"    { value = module.karpenter.node_iam_role_name }
output "karpenter_irsa_arn"     { value = module.karpenter.iam_role_arn }

The EKS cluster options that matter for a production cluster, and what each buys you:

Option Setting Why
cluster_endpoint_private_access true API reachable inside the VPC; nodes never traverse the internet to the API
cluster_endpoint_public_access true (locked CIDRs) or false Public off is strongest; if on, restrict to office/CI CIDRs
cluster_enabled_log_types api,audit,authenticator,controllerManager,scheduler Control-plane audit trail to CloudWatch
cluster_encryption_config { resources = ["secrets"] } KMS envelope encryption of Kubernetes Secrets at rest
authentication_mode API_AND_CONFIG_MAP Access entries; migrate off aws-auth without lockout
cluster_addons coredns, kube-proxy, vpc-cni, pod-identity-agent Core add-ons versioned with the cluster
managed NG taints CriticalAddonsOnly=true:NoSchedule Reserve system nodes for add-ons; app pods go to Karpenter

The platform add-ons layer: IRSA + Helm, the star of the show

This is the heart of the capstone. The platform layer reads the already-created cluster, configures the kubernetes and helm providers against it, and installs every add-on — each with its own IRSA role and no permission on the node. First the providers, authenticated by exec so no token is ever written to state:

# live/prod/platform/versions.tf
terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws        = { source = "hashicorp/aws",        version = "~> 5.0" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.35" }
    helm       = { source = "hashicorp/helm",       version = "~> 2.17" }
  }
  backend "s3" {}   # key = prod/platform/terraform.tfstate
}

data "terraform_remote_state" "cluster" {
  backend = "s3"
  config  = { bucket = "kestrel-tfstate-apsouth1", key = "prod/cluster/terraform.tfstate", region = "ap-south-1" }
}

# Read the LIVE cluster the cluster layer created — never create it here
data "aws_eks_cluster" "this" {
  name = data.terraform_remote_state.cluster.outputs.cluster_name
}

provider "aws" { region = var.region }

provider "kubernetes" {
  host                   = data.aws_eks_cluster.this.endpoint
  cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args        = ["eks", "get-token", "--cluster-name", data.aws_eks_cluster.this.name]
  }
}

provider "helm" {
  kubernetes {
    host                   = data.aws_eks_cluster.this.endpoint
    cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args        = ["eks", "get-token", "--cluster-name", data.aws_eks_cluster.this.name]
    }
  }
}

The IRSA + Helm pattern, once. Every add-on is the same two-part shape: an IAM role scoped to exactly what that controller needs, trusted by the cluster’s OIDC provider for one namespace/service-account pair, and a helm_release that annotates its service account with the role ARN. The terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks module ships the canonical policy for each well-known controller behind a boolean, so you don’t hand-write the JSON. Here is the AWS Load Balancer Controller in full:

# live/prod/platform/main.tf
locals {
  oidc_provider_arn = data.terraform_remote_state.cluster.outputs.oidc_provider_arn
  cluster_name      = data.aws_eks_cluster.this.name
}

module "irsa_lb_controller" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version = "~> 5.0"

  role_name                              = "irsa-lbc-${local.cluster_name}"
  attach_load_balancer_controller_policy = true          # canonical AWS LBC policy, maintained upstream
  oidc_providers = {
    main = {
      provider_arn               = local.oidc_provider_arn
      namespace_service_accounts = ["kube-system:aws-load-balancer-controller"]
    }
  }
}

resource "helm_release" "aws_lb_controller" {
  name       = "aws-load-balancer-controller"
  repository = "https://aws.github.io/eks-charts"
  chart      = "aws-load-balancer-controller"
  version    = var.lbc_chart_version           # e.g. "1.8.1"
  namespace  = "kube-system"

  set{
    name = "clusterName"
    value = local.cluster_name
  }
  set{
    name = "serviceAccount.create"
    value = "true"
  }
  set{
    name = "serviceAccount.name"
    value = "aws-load-balancer-controller"
  }
  set {
    name  = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
    value = module.irsa_lb_controller.iam_role_arn      # ← the IRSA wire-up
  }
}

ExternalDNS is the same shape, scoped to just the one hosted zone it may edit:

module "irsa_external_dns" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version = "~> 5.0"

  role_name                     = "irsa-extdns-${local.cluster_name}"
  attach_external_dns_policy    = true
  external_dns_hosted_zone_arns = [var.hosted_zone_arn]     # ← least-privilege: one zone
  oidc_providers = {
    main = { provider_arn = local.oidc_provider_arn, namespace_service_accounts = ["kube-system:external-dns"] }
  }
}

resource "helm_release" "external_dns" {
  name       = "external-dns"
  repository = "https://kubernetes-sigs.github.io/external-dns/"
  chart      = "external-dns"
  version    = var.external_dns_chart_version
  namespace  = "kube-system"

  set{
    name = "provider"
    value = "aws"
  }
  set {                                                   # create AND delete records it owns
    name  = "policy"
    value = "sync"
  }
  set{
    name = "domainFilters[0]"
    value = var.public_domain
  }
  set{
    name = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
    value = module.irsa_external_dns.iam_role_arn
  }
}

Eight add-ons written out longhand is eight near-identical blocks — the thing modules exist to kill. In a real library you extract the shape into a small eks-platform-addons module driven by a for_each map, so the root declares add-ons as data and the module builds the IRSA role + helm_release for each. That is the reusability payoff, and the module registry equivalent is exactly the iam-role-for-service-accounts-eks submodule you already saw. The full add-on catalogue — what each does, the chart, and the IRSA policy it needs — is the reference you build the map from:

Add-on Namespace / SA Chart (repo) IRSA policy (attach_*)
AWS Load Balancer Controller kube-system / aws-load-balancer-controller aws-load-balancer-controller (eks-charts) load_balancer_controller
ExternalDNS kube-system / external-dns external-dns (sigs) external_dns (one zone)
metrics-server kube-system / metrics-server metrics-server (sigs) none (no AWS calls)
Karpenter kube-system / karpenter karpenter (public.ecr.aws) from the cluster-layer karpenter module
EBS CSI driver kube-system / ebs-csi-controller-sa aws-ebs-csi-driver (EKS addon) ebs_csi
EFS CSI driver kube-system / efs-csi-controller-sa aws-efs-csi-driver (sigs) efs_csi
CloudWatch Observability amazon-cloudwatch / cloudwatch-agent amazon-cloudwatch-observability (EKS addon) CloudWatchAgentServerPolicy
kube-prometheus-stack monitoring / * kube-prometheus-stack (prometheus-community) none (Prometheus scrapes in-cluster)
External Secrets Operator external-secrets / external-secrets external-secrets (ESO) external_secrets (scoped secrets)
cert-manager cert-manager / cert-manager cert-manager (jetstack) cert_manager (if DNS-01)
Argo CD argocd / argocd-* argo-cd (argoproj) none (syncs from Git)
Velero velero / velero velero (vmware-tanzu) velero (S3 + EBS snapshot)

The IRSA wire-up is the single most important security property of the platform, so state it as a rule: one role per controller, trusted for one service account, granting exactly that controller’s permissions. The node role, by contrast, holds almost nothing — just the managed policies EKS requires (AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, ECR read). The IRSA-per-add-on catalogue:

Controller Assumes role for Grants (scope) Never on the node role
LB Controller kube-system:aws-load-balancer-controller ELB create/modify, describe SG/subnets
ExternalDNS kube-system:external-dns Route 53 change on one zone
EBS CSI kube-system:ebs-csi-controller-sa Create/attach EBS volumes
EFS CSI kube-system:efs-csi-controller-sa Describe/mount EFS access points
External Secrets external-secrets:external-secrets secretsmanager:GetSecretValue on a prefix
Velero velero:velero S3 rw on the backup bucket + EBS snapshot
Karpenter kube-system:karpenter RunInstances / TerminateInstances (tag-scoped)

IRSA vs Pod Identity. IRSA (the OIDC-federation model) is the mature, universal path and what the iam-role-for-service-accounts-eks module builds. EKS Pod Identity (an agent add-on + an association API) is the newer, simpler model — no OIDC provider per cluster, no SA annotation, an association object instead. Use IRSA when a chart hard-codes the annotation or you need cross-account; prefer Pod Identity for new, in-account add-ons where you control the SA. Karpenter above uses Pod Identity via create_pod_identity_association; the rest use IRSA — mixing is normal, and the platform layer is where both live.

Axis IRSA (OIDC federation) EKS Pod Identity
Trust anchor Per-cluster OIDC provider eks-pod-identity-agent add-on
Wire-up SA annotation eks.amazonaws.com/role-arn aws_eks_pod_identity_association object
Role trust sts:AssumeRoleWithWebIdentity on the sub pods.eks.amazonaws.com principal
Cross-account Supported Same-account (assoc is cluster-local)
Reuse across clusters New OIDC trust per cluster One role, associations per cluster
Best for Charts that hard-code the annotation New in-account add-ons you control

Argo CD and the Terraform/GitOps seam. Terraform installs Argo CD as one more Helm release; from there, Argo CD owns application delivery. The boundary is deliberate and it is the answer to “why aren’t the apps in Terraform?”: infrastructure changes go through plan/apply with an IAM audit trail; application changes go through a PR to the manifests repo with Argo CD’s sync and rollback. The seam is drawn at the platform/app line so Terraform and kubectl never fight over the same Deployment’s replica count.

resource "helm_release" "argocd" {
  name             = "argocd"
  repository       = "https://argoproj.github.io/argo-helm"
  chart            = "argo-cd"
  version          = var.argocd_chart_version
  namespace        = "argocd"
  create_namespace = true
  values           = [yamlencode({ server = { ingress = { enabled = true } } })]
}

# The ONE app-of-apps that points Argo CD at the Git repo; everything else syncs from there
resource "kubernetes_manifest" "root_app" {
  manifest = {
    apiVersion = "argoproj.io/v1alpha1"
    kind       = "Application"
    metadata   = { name = "root", namespace = "argocd" }
    spec = {
      project     = "default"
      source      = { repoURL = var.gitops_repo, path = "envs/${var.environment}", targetRevision = "main" }
      destination = { server = "https://kubernetes.default.svc", namespace = "argocd" }
      syncPolicy  = { automated = { prune = true, selfHeal = true } }
    }
  }
  depends_on = [helm_release.argocd]
}

Multi-environment: dev, staging, prod

The layers are identical across environments; only the inputs differ. dev runs cheap and spot-heavy with the API endpoint open to the office CIDR; prod runs prod-sized, endpoint-locked, Multi-add-on-replica, and Velero-backed. Each environment has its own state key per layer (the <env>/ prefix), so a dev apply is physically incapable of touching prod. The per-environment sizing that carries every difference — the only thing that changes, because the module and add-on code is identical:

Setting Variable dev staging prod
Kubernetes version cluster_version 1.30 1.30 1.30 (upgraded last)
API public access endpoint_public_access true (office CIDR) true (office+CI) false (private only)
System NG size system_instance_types / min-max t3.large / 1–2 m6i.large / 2–3 m6i.large / 2–4
Karpenter capacity NodePool capacity_type ["spot"] ["spot","on-demand"] ["spot","on-demand"]
Karpenter CPU limit NodePool limits.cpu 50 200 1000
Argo CD / ESO replicas chart replicas 1 2 2 (HA)
Prometheus retention chart retention 2d 7d 30d
CloudWatch log retention log_retention_days 7 30 90
Velero backups enable_velero false true (daily) true (hourly + daily)
Network policy enforce enforce_netpol false (audit) true true
Monthly budget (INR) monthly_budget 15000 60000 180000

Promotion walks a change dev → staging → prod: prove the add-on chart bump or NodePool change in dev, run the same code against staging with staging inputs, then prod behind an approval. Nothing is hand-edited between environments; only the sizing variables change. This is the promotion discipline from the multi-environment Terragrunt lesson applied to a whole platform rather than a single stack:

Stage What runs Gate before it What differs
dev plan + apply on merge PR review Spot-only, endpoint open, 1 replica, no Velero
staging plan + apply dev green + soak Prod-like sizing, netpol enforced, Velero daily
prod plan (posted) then apply Manual approval Endpoint private, HA replicas, Velero hourly, real budget

Security & SRE as code

Reliability and security are properties of the platform, guaranteed by the layers and the IRSA discipline, not bolted on after the first incident. The controls, densest part of the lesson, each a small standard pattern applied everywhere:

Control How it’s enforced Standard
Workload identity IRSA / Pod Identity per add-on No node-role app perms; no static keys in Secrets
API exposure Private endpoint; public locked or off Prod endpoint private; nodes reach API in-VPC
Secrets at rest KMS envelope encryption (resources=["secrets"]) Kubernetes Secrets encrypted above EBS level
Secret delivery External Secrets Operator + IRSA App secrets pulled from Secrets Manager, never committed
Encryption in transit TLS at ALB (ACM) + cert-manager in-cluster HTTPS to the edge; mTLS optional via mesh
East-west traffic NetworkPolicy (default-deny) Namespaces deny by default; allow-list explicitly
Pod hardening Pod Security Standards restricted Namespace labels enforce non-root, no privilege
Cost Karpenter spot + consolidation + right-size Workloads on spot; idle nodes consolidated
Observability Prometheus/CloudWatch + alerts to SNS Golden signals + control-plane logs; alarms per env
Backup / DR Velero (schedules + EBS snapshots) Namespaced + PV backups to S3, cross-region copy
Change safety Separate state per layer; CI role per key Blast radius one layer; least-privilege deploy role
Supply chain checkov/tfsec on Terraform; image scanning Required status check; no unpinned charts

Four of these deserve prose because they are where EKS platforms actually fail.

Pod Security Standards and network policy are namespace-level, and belong to apps, not the platform. Terraform (or Argo CD) labels each app namespace pod-security.kubernetes.io/enforce: restricted so a pod that runs as root or asks for privilege is rejected at admission; a default-deny NetworkPolicy per namespace forces east-west traffic to be allow-listed. Start in audit mode in dev (label warn/audit) and flip to enforce in staging/prod so you learn what breaks before it blocks a deploy.

Cost is a first-class control, and Karpenter is the lever. The largest EKS surprises are (1) the flat control-plane fee (~₹6,000/month per cluster, unavoidable — fold small teams into fewer clusters with namespaces, not a cluster each), (2) idle on-demand nodes, and (3) NAT and cross-AZ transfer. Karpenter attacks the second: a broad instance set, spot preferred with on-demand fallback, and consolidationPolicy: WhenEmptyOrUnderutilized so under-used nodes are drained and replaced with cheaper/smaller ones. Set a NodePool CPU limit so a runaway workload can’t provision unbounded capacity, and watch it with a budget alarm.

Upgrades are ordered, and the order is not negotiable. Upgrade the control plane first, then the managed node group and Karpenter nodes, then the add-ons — never nodes ahead of the API server. Kubelet may lag the control plane (EKS tolerates several minors behind on the extended path) but must never lead it, and you upgrade one minor at a time — no skipping. After the control plane bump, roll nodes (a new AMI/launch template triggers the managed-NG roll; Karpenter drift-replaces its nodes), then match each add-on’s chart/version to the new cluster version.

Upgrade step What you bump Skew rule Verify
1. Control plane cluster_version (one minor) Never skip a minor aws eks describe-cluster shows new version
2. Core add-ons coredns/kube-proxy/vpc-cni versions Match to cluster version Add-on ACTIVE, no DEGRADED
3. Managed node group AMI / cluster_version on the NG Kubelet ≤ control plane, ≥ N-3 Nodes Ready, new version
4. Karpenter nodes Karpenter drift / NodePool Same skew as managed nodes kubectl get nodes new kubelet
5. Platform add-ons Helm chart versions Chart supports the new K8s API Pods Running, CRDs intact

Backup and DR is Velero, and it needs its own IRSA. Velero backs up namespaced objects and, via the CSI/EBS integration, the persistent volumes; schedules push to an S3 bucket (copied cross-region for DR), and its IRSA role grants S3 read/write plus EBS snapshot. Test the restore, not just the backup — a backup you have never restored is a hope, not a plan.

DR concern What Velero does The wire-up
Cluster objects Backs up namespaced manifests to S3 Schedule CR (hourly + daily in prod)
Persistent volumes EBS/CSI volume snapshots VolumeSnapshotLocation + CSI plugin
Backup store S3 bucket, cross-region copy for DR BackupStorageLocation (must be Available)
Permissions S3 rw + ec2:CreateSnapshot/DescribeVolumes Velero IRSA role (attach_velero_policy)
Recovery Restore into a rebuilt cluster velero restore create --from-backup … — rehearse it

The CI/CD gate that ties it together is the OIDC pipeline (covered in the GitHub Actions OIDC lesson): a PR runs fmt/validate/tflint/checkov and a plan per changed layer under a read-only role assumed via GitHub OIDC (no static keys); a merge assumes a scoped deploy role — one per layer, able to touch only that layer’s state key prefix — and applies the approved plan in apply order. Reference it as the enforcement plane; the platform above is what it enforces.

Hands-on: assemble the platform

Now assemble the platform end to end — the four layers, in order, with the cross-layer reads, the IRSA add-ons and Argo CD. ⚠️ This creates real, billable AWS resources — an EKS control plane (~₹6,000/month, billed from creation), NAT gateways, EC2 worker nodes, EBS volumes and one or more ALBs. Destroy in reverse order at the end.

Step 0 — one-time state backend (bootstrap). The S3 bucket and lock table must exist before any layer can init. Create them once by hand:

aws s3api create-bucket --bucket kestrel-tfstate-apsouth1 \
  --region ap-south-1 --create-bucket-configuration LocationConstraint=ap-south-1
aws s3api put-bucket-versioning --bucket kestrel-tfstate-apsouth1 \
  --versioning-configuration Status=Enabled
aws dynamodb create-table --table-name kestrel-tf-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST --region ap-south-1

Step 1 — apply network. The VPC layer stands up subnets across three AZs with the discovery tags EKS, the LB Controller and Karpenter need (kubernetes.io/role/elb on public, kubernetes.io/role/internal-elb on private, karpenter.sh/discovery = <cluster> on private):

cd live/prod/network
terraform init -backend-config=backend.hcl     # key = prod/network/terraform.tfstate
terraform apply

Step 2 — apply cluster. ⚠️ This creates the EKS control plane (billing starts now) and takes ~12–15 minutes:

cd ../cluster
terraform init -backend-config=backend.hcl     # key = prod/cluster/terraform.tfstate
terraform plan  -out=cluster.tfplan
terraform apply cluster.tfplan
aws eks update-kubeconfig --name eks-kestrel-prod --region ap-south-1   # for verification

Step 3 — apply platform. This is the layer that would have failed had you folded it into cluster: it reads the live cluster via the data source, configures the kubernetes/helm providers, and installs every add-on with its IRSA role. Representative plan output — note the IRSA modules and helm_releases, and that Terraform reads the cluster (not creates it):

cd ../platform
terraform init -backend-config=backend.hcl     # key = prod/platform/terraform.tfstate
terraform plan  -out=platform.tfplan
terraform apply platform.tfplan
data.terraform_remote_state.cluster: Reading...
data.aws_eks_cluster.this: Reading...

Terraform will perform the following actions:
  # module.irsa_lb_controller.aws_iam_role.this[0] will be created
  # module.irsa_external_dns.aws_iam_role.this[0] will be created
  # helm_release.aws_lb_controller will be created
  # helm_release.external_dns will be created
  # helm_release.metrics_server will be created
  # helm_release.karpenter will be created
  # helm_release.external_secrets will be created
  # helm_release.argocd will be created
  # kubernetes_manifest.root_app will be created

Plan: 18 to add, 0 to change, 0 to destroy.

Step 4 — verify the platform properties, not just that pods are running: that IRSA is wired (a pod assumes its role, not the node role), the LB Controller is up, and ExternalDNS owns records:

# Every controller Running, each with its IRSA-annotated service account
kubectl get pods -n kube-system | egrep 'aws-load-balancer|external-dns|karpenter|metrics-server'

# IRSA proof: the SA carries the role-arn annotation
kubectl get sa aws-load-balancer-controller -n kube-system \
  -o jsonpath='{.metadata.annotations.eks\.amazonaws\.com/role-arn}{"\n"}'

# Argo CD came up and the app-of-apps is syncing
kubectl get applications -n argocd

Step 5 — hand off to Argo CD (the apps layer). The apps layer is thin: it is the app-of-apps Application pointing at the Git repo; from there Argo CD syncs the actual workloads. You do not helm_release each app in Terraform — that is the seam.

Step 6 — destroy in reverse. ⚠️ Destroy apps → platform → cluster → network. Destroying network first strands ALBs/ENIs the add-ons created and the VPC delete hangs:

cd ../platform && terraform destroy    # removes helm releases + IRSA roles first
cd ../cluster  && terraform destroy    # then the cluster (stops the control-plane billing)
cd ../network  && terraform destroy    # finally the VPC
# only if fully finished with the backend:
aws s3 rb s3://kestrel-tfstate-apsouth1 --force
aws dynamodb delete-table --table-name kestrel-tf-locks --region ap-south-1

Variables, outputs & making it reusable: the module map

The whole lesson is about reuse, so the last mile is the module map and the build-vs-buy call. The honest answer is not “always community” — it depends on how much of the surface is commodity versus a platform opinion you must own and defend. The map Kestrel settled on, a reasonable default for a mid-size estate:

Building block Community option Kestrel’s call Why
VPC terraform-aws-modules/vpc/aws Adopt Commodity, fiddly, well-maintained
EKS cluster terraform-aws-modules/eks/aws Adopt Access entries, add-ons, node groups done right
Karpenter IAM …/eks/aws//modules/karpenter Adopt Node role + queue + controller policy in one block
IRSA roles …/iam/aws//modules/iam-role-for-service-accounts-eks Adopt Canonical per-controller policies behind booleans
Add-ons composition your eks-platform-addons module Own Encodes your add-on set, versions and IRSA wiring
NodePool / EC2NodeClass your karpenter-nodepool module Own Your instance families, taints, consolidation policy
Helm charts upstream charts Adopt (pin!) Community-maintained; pin the version, promote bumps

The decision grid, and the trap: community modules and charts change on someone else’s schedule. An unpinned source or chart version means a future init/upgrade pulls a new major and blows up an environment you didn’t touch. Always pin (~> for modules, an exact chart version), read the CHANGELOG before a bump, and upgrade dev first — the same promotion flow you use for infrastructure applies to the library and the charts.

Question Own it Adopt community
Is the surface commodity (VPC, EKS, IRSA policy)? No → own Yes → adopt
Does it encode a platform opinion (your add-on set, NodePool shape)? Yes → own No → adopt
Who maintains hardening + upgrades? You Community + you (pin + review)
Learning value now? High Lower (you consume)

The reusability payoff is that the root declares add-ons as data — a map of { name, chart, version, irsa_policy } — and the eks-platform-addons module builds the IRSA role + helm_release per entry with for_each. Adding an add-on becomes one map entry, reviewed in a PR, promoted dev → prod, never a copy-paste of forty lines.

Common mistakes and troubleshooting

Platform failures cluster around layer ordering, IRSA wiring, Karpenter discovery, upgrade skew, and the Terraform/GitOps seam. This is the operations map:

Symptom Likely cause Fix
helm_release fails: provider config unknown / no cluster Add-ons in the cluster’s own config, or platform applied before cluster Separate states; apply network→cluster→platform; platform reads the cluster
Pod AccessDenied despite an IRSA role SA missing the role-arn annotation, trust sub wrong, or pod-identity-agent absent Check the SA annotation; the role trust must match system:serviceaccount:<ns>:<sa>; install the agent for Pod Identity
Ingress creates no ALB LB Controller not running, IRSA policy missing, or subnet tags absent Check controller logs; attach_load_balancer_controller_policy; kubernetes.io/role/elb on public subnets
ExternalDNS writes no records IRSA lacks Route 53, wrong --domain-filter, or policy not sync Scope IRSA to the zone ARN; set domainFilters; policy=sync to allow deletes
Karpenter launches no nodes Missing karpenter.sh/discovery tag on subnets/SG, or NodePool too narrow Tag subnets + node SG; widen the NodePool instance categories; check kubectl get nodeclaim
Nodes NotReady after an upgrade Nodes upgraded ahead of the control plane, or a skipped minor Control plane first, one minor at a time, then roll nodes
Add-on CrashLoops after a cluster upgrade Chart/add-on version not matched to the new K8s version Bump the add-on chart to the version supporting the new API
dev plan proposes to destroy prod add-ons Two layers share one state key Distinct key per env AND per layer; re-init -backend-config
Error acquiring the state lock A dead run or teammate holds the DynamoDB lock Confirm no live apply, then terraform force-unlock <ID>; never auto-retry in CI
Terraform wants to revert a Deployment someone kubectl edit-ed Ownership straddles the Terraform/GitOps seam Move app resources to Argo CD; Terraform stops at Argo CD install
Velero restore fails IRSA missing S3/snapshot perms, or wrong backup location Grant the velero policy; verify the BackupStorageLocation is Available
Cost creeping up On-demand instead of spot, or no consolidation Prefer spot in the NodePool; consolidationPolicy: WhenEmptyOrUnderutilized; set a CPU limit + budget

Five gotchas cost real hours and deserve prose:

The provider-ordering problem is the whole reason for the cluster/platform split. If you take one thing from this lesson: a kubernetes or helm provider must be configured from a cluster that already exists in another state, read via data "aws_eks_cluster". Fold the add-ons into the cluster config and it works until the first rebuild or destroy, then fails because a provider can’t reach a cluster that isn’t there. Separate the states; the platform layer reads, never creates, the cluster.

IRSA fails silently, then loudly. The classic is a controller that seems fine until it makes its first AWS call and gets AccessDenied. Debug it in order: is the SA annotated with the role ARN? Does the role’s trust policy condition on the exact system:serviceaccount:<namespace>:<name>? For Pod Identity, is the eks-pod-identity-agent add-on installed and the association created? kubectl exec a pod and run aws sts get-caller-identity — if it returns the node role, the wire-up is wrong.

Karpenter discovery is tag-driven. Karpenter finds subnets and security groups by tag (karpenter.sh/discovery = <cluster-name>), and its EC2NodeClass references them by that tag selector. Forget the tag (it lives in the network layer) and Karpenter provisions nothing, with a quiet “no matching subnets” in its logs. Tag in the network layer, select by tag in the NodePool.

Upgrade order is not advice, it is a constraint. Kubelet must never lead the API server. Bump cluster_version one minor, let the control plane go green, upgrade the core add-ons to match, then roll the managed node group and let Karpenter drift-replace its nodes, then the platform charts. Skip a minor or upgrade nodes first and you get NotReady nodes or admission failures.

Draw the Terraform/GitOps line and defend it. The instant both Terraform and Argo CD (or a human with kubectl) manage the same object, you get an infinite reconcile loop or drift. Terraform’s job ends at installing Argo CD and the platform add-ons; everything above — the apps — is Argo CD’s. If you find Terraform planning to revert an app change, the object is on the wrong side of the seam.

Cost, cleanup & production notes

The platform bills whether or not traffic flows. The rough monthly cost if left running in ap-south-1, and the levers:

Resource Cost driver Rough monthly (dev) Notes
EKS control plane Flat per-cluster fee ~₹6,000 Unavoidable per cluster; consolidate small teams into namespaces
NAT gateway Hourly + per-GB ₹3,000–4,000 each single_nat_gateway in dev; the quiet big one
System node group On-demand instance-hours ₹2,000+ Keep it small; it only runs add-ons
Karpenter workers Spot instance-hours Variable (spot) Spot + consolidation is the saving
ALB(s) Hourly + LCU ₹1,500–2,500 each Share via IngressGroup; don’t spawn one per Ingress
EBS volumes gp3 GB-month ₹10s–100s Delete PVCs you don’t need; reclaimPolicy
CloudWatch logs Ingest + storage ₹100s Control-plane logs + Container Insights; set retention
Data transfer Cross-AZ + egress Variable Cross-AZ pod chatter adds up at scale

The dominant surprises are the control-plane fee (per cluster, so prefer fewer clusters with namespaces over a cluster per team), the NAT gateway (hourly even idle — single NAT in non-prod), and on-demand nodes (which is exactly what Karpenter spot + consolidation attacks). Destroy in reverse layer order; keep the bootstrap bucket and lock table unless you are fully finished.

Five production-hardening notes to carry beyond the demo:

  1. State is the crown jewels — remote, locked (DynamoDB or use_lockfile on TF 1.10+), versioned and encrypted, one key per env per layer. A CI deploy role is scoped to a single key prefix, so a leaked credential is contained to one layer.
  2. IRSA everywhere, nothing on the node — every controller and app that touches AWS assumes its own role; the node role holds only what EKS requires. No static keys in any Secret; app secrets arrive via the External Secrets Operator.
  3. Private where it counts — private API endpoint in prod (public off), private subnets for nodes, KMS envelope encryption for Secrets, and default-deny NetworkPolicy with Pod Security Standards restricted.
  4. Upgrade in order, on a cadence — control plane → core add-ons → nodes → platform charts, one minor at a time, with the skew rule; rehearse it in dev/staging before prod, and keep Velero restores tested.
  5. Draw the platform/app seam — Terraform owns the cluster and add-ons; Argo CD owns the apps. The day someone kubectl edits a Terraform-managed object is the day drift begins.

Cheat-sheet

The dense quick-reference for assembling an EKS platform with Terraform.

Layers & state

Layer Owns Reads State key
network VPC, subnets, tags, NAT <env>/network/…
cluster EKS, system NG, KMS, OIDC, Karpenter IAM network <env>/cluster/…
platform IRSA + Helm add-ons + Argo CD cluster <env>/platform/…
apps Argo CD Application → Git platform <env>/apps/…

Core modules & charts

Thing Module / chart
VPC terraform-aws-modules/vpc/aws ~> 5.8
EKS terraform-aws-modules/eks/aws ~> 20.0
Karpenter IAM …/eks/aws//modules/karpenter ~> 20.0
IRSA role …/iam/aws//modules/iam-role-for-service-accounts-eks ~> 5.0
LB Controller chart aws-load-balancer-controller (eks-charts)
ExternalDNS chart external-dns (sigs)
Karpenter chart karpenter (oci://public.ecr.aws/karpenter)
External Secrets chart external-secrets (ESO)
Argo CD chart argo-cd (argoproj)
Velero chart velero (vmware-tanzu)

Providers on EKS

Provider Auth
aws ~> 5.0 SSO / assumed role; default_tags
kubernetes ~> 2.35 execaws eks get-token (never a static token)
helm ~> 2.17 nested kubernetes {} block; v3 uses kubernetes = {}

Commands

Command Use
terraform init -backend-config=backend.hcl Bind the env/layer state key
apply network → cluster → platform → apps The fixed apply order
destroy apps → platform → cluster → network The reverse (destroy order)
aws eks update-kubeconfig --name <c> kubeconfig for verification
kubectl get sa <sa> -o jsonpath=…role-arn Prove IRSA wire-up
kubectl get nodeclaim Karpenter provisioning state
terraform force-unlock <ID> Release a stuck lock

Interview and exam questions

1. Why split the EKS platform into separate cluster and platform states? The provider-ordering problem: the kubernetes/helm providers are configured at plan time from the cluster’s endpoint, and a provider cannot depend on a resource created in its own apply. Folding add-ons into the cluster config works on a clean first apply but fails on rebuild/destroy. The platform layer reads an already-created cluster via data "aws_eks_cluster" and only then configures the providers. Blast radius, team ownership and apply time reinforce the split.

2. What is IRSA and why prefer it over node-role permissions or static keys? IRSA (IAM Roles for Service Accounts) federates the cluster’s OIDC provider to IAM so a service account assumes its own role via sts:AssumeRoleWithWebIdentity. A permission on the node role is granted to every pod on the node; a static key in a Secret can leak and never rotates. IRSA scopes each controller to exactly its permissions, trusted for one namespace/SA, with no long-lived credential.

3. IRSA vs EKS Pod Identity — when each? IRSA is the mature, universal path (per-cluster OIDC provider + SA annotation), needed when a chart hard-codes the annotation or you go cross-account. Pod Identity is newer and simpler — an agent add-on plus an association object, no OIDC provider or annotation. Prefer Pod Identity for new in-account add-ons you control; mixing is normal.

4. State the apply order and the destroy order, and why the destroy order matters. Apply network → cluster → platform → apps; destroy in reverse apps → platform → cluster → network. Destroy order matters because add-ons create AWS objects (ALBs, ENIs, security-group rules) inside the VPC; tearing down network first strands them and the VPC delete hangs. The order is a property of the dependency graph, not memory — Terragrunt run-all walks it.

5. Give the cluster upgrade order and the version-skew rule. Control plane first (one minor, never skip), then core add-ons to match, then the managed node group and Karpenter nodes, then the platform charts. Kubelet may lag the control plane (several minors on the extended path) but must never lead it. Match each add-on version to the new cluster version.

6. Why run a small managed node group and Karpenter? Chicken-and-egg: Karpenter, CoreDNS and the CSI drivers need somewhere to run before Karpenter can provision. A tiny on-demand managed group (tainted CriticalAddonsOnly) runs the system add-ons; Karpenter provisions right-sized, mostly-spot workload nodes and consolidates them when idle.

7. How does the helm provider authenticate to EKS, and why not a static token? Via an exec block that calls aws eks get-token at apply time, yielding a short-lived token. A data "aws_eks_cluster_auth" token would be baked into state and expire; exec fetches a fresh token per run and keeps nothing sensitive in state.

8. Where is the Terraform/GitOps boundary, and why draw it there? Terraform owns the cluster and platform add-ons and installs Argo CD; Argo CD owns application delivery from Git. Drawn at the platform/app seam, it keeps infra changes on plan/apply with an IAM audit trail and app changes on PRs with Argo CD sync/rollback — and stops Terraform and kubectl fighting over the same object.

9. How does Karpenter reduce cost, and what guardrail prevents a runaway? A broad instance set with spot preferred and on-demand fallback, plus consolidationPolicy: WhenEmptyOrUnderutilized to drain under-used nodes onto cheaper capacity. The guardrail is a NodePool CPU limit so a misbehaving workload can’t provision unbounded nodes, watched by a budget alarm.

10. What does KMS envelope encryption on EKS protect, above EBS encryption? cluster_encryption_config { resources = ["secrets"] } encrypts Kubernetes Secrets in etcd with a KMS key (envelope encryption), so a Secret is protected at the application layer, not just by the underlying EBS volume encryption of the control-plane storage. It closes the gap where a Secret would otherwise sit in etcd in plaintext.

11. (Associate-style) A backend "s3" block sets key = "${var.env}/cluster/terraform.tfstate". What happens? It fails at init — the backend is read before variables/locals are evaluated, so no interpolation is allowed there. Move the value to partial config: terraform init -backend-config=backend.hcl (or -backend-config="key=prod/cluster/terraform.tfstate").

12. How do the layers pass data, and how does Terragrunt improve on the native approach? Natively, a lower layer reads an upper layer’s outputs with a data "terraform_remote_state" source keyed to the other layer’s state. Terragrunt replaces that with a first-class dependency block (with mock_outputs so a plan runs before the dependency exists) and can run-all across the dependency graph in the correct order automatically.

Key takeaways

TerraformTerragruntawsAWSEKSKubernetesIRSAKarpenterHelmArgoCDGitOpsremote-stateIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments