AWS
15 min readMay 7, 2026

Terraform for EKS: Complete Infrastructure as Code Guide

Clicking through the AWS console to create an EKS cluster is fine for learning. Running production EKS that way means undocumented infrastructure, no change history, and no way to rebuild after a disaster. Terraform lets you define your EKS cluster, VPC, node groups, IAM roles, and addons in code — version-controlled, reviewable, and reproducible.

CO
Coding Protocols Team
Platform Engineering
Terraform for EKS: Complete Infrastructure as Code Guide

EKS infrastructure has a lot of moving parts: the VPC and subnets, the control plane, managed node groups, IAM roles for nodes and workloads, cluster addons (VPC CNI, CoreDNS, EBS CSI driver), security groups, and logging. Managing all of this with Terraform gives you a complete audit trail of every change, the ability to preview infrastructure changes before applying, and a reproducible blueprint for creating identical clusters across environments.

The community-maintained terraform-aws-eks module handles most of the EKS complexity. This guide covers how to use it for a production-grade cluster with managed node groups, EKS addons, and Pod Identity.


Project Structure

eks-infra/
├── main.tf             # EKS cluster + node groups
├── vpc.tf              # VPC and subnets
├── iam.tf              # IAM roles for addons
├── addons.tf           # EKS cluster addons
├── variables.tf
├── outputs.tf
└── backend.tf          # Remote state configuration

Remote State

Always configure remote state before writing any resources. S3 + DynamoDB for locking is the standard setup:

hcl
1# backend.tf
2terraform {
3  required_version = ">= 1.6"
4
5  required_providers {
6    aws = {
7      source  = "hashicorp/aws"
8      version = "~> 5.0"
9    }
10    kubernetes = {
11      source  = "hashicorp/kubernetes"
12      version = "~> 2.0"
13    }
14  }
15
16  backend "s3" {
17    bucket         = "my-org-terraform-state"
18    key            = "eks/production/terraform.tfstate"
19    region         = "us-east-1"
20    encrypt        = true
21    dynamodb_table = "terraform-state-lock"
22  }
23}
24
25provider "aws" {
26  region = var.aws_region
27
28  default_tags {
29    tags = {
30      ManagedBy   = "terraform"
31      Environment = var.environment
32      Cluster     = var.cluster_name
33    }
34  }
35}

Create the S3 bucket and DynamoDB table before terraform init:

bash
1aws s3api create-bucket \
2  --bucket my-org-terraform-state \
3  --region us-east-1 \
4  --create-bucket-configuration LocationConstraint=us-east-1
5
6aws s3api put-bucket-versioning \
7  --bucket my-org-terraform-state \
8  --versioning-configuration Status=Enabled
9
10aws s3api put-bucket-encryption \
11  --bucket my-org-terraform-state \
12  --server-side-encryption-configuration \
13  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
14
15aws dynamodb create-table \
16  --table-name terraform-state-lock \
17  --attribute-definitions AttributeName=LockID,AttributeType=S \
18  --key-schema AttributeName=LockID,KeyType=HASH \
19  --billing-mode PAY_PER_REQUEST \
20  --region us-east-1

VPC

hcl
1# vpc.tf
2module "vpc" {
3  source  = "terraform-aws-modules/vpc/aws"
4  version = "~> 5.0"
5
6  name = "${var.cluster_name}-vpc"
7  cidr = "10.0.0.0/16"
8
9  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
10  private_subnets = ["10.0.1.0/18", "10.0.65.0/18", "10.0.129.0/18"]
11  public_subnets  = ["10.0.192.0/24", "10.0.193.0/24", "10.0.194.0/24"]
12
13  enable_nat_gateway     = true
14  single_nat_gateway     = false   # One NAT GW per AZ for HA
15  one_nat_gateway_per_az = true
16
17  enable_dns_hostnames = true
18  enable_dns_support   = true
19
20  # Required tags for EKS to discover subnets
21  public_subnet_tags = {
22    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
23    "kubernetes.io/role/elb"                    = "1"
24  }
25
26  private_subnet_tags = {
27    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
28    "kubernetes.io/role/internal-elb"           = "1"
29    # Tag for Karpenter node provisioning
30    "karpenter.sh/discovery" = var.cluster_name
31  }
32}

EKS Cluster

hcl
1# main.tf
2module "eks" {
3  source  = "terraform-aws-modules/eks/aws"
4  version = "~> 20.0"  # terraform-aws-modules/eks is in the v20.x series — verify latest at https://registry.terraform.io/modules/terraform-aws-modules/eks/aws
5
6  cluster_name    = var.cluster_name
7  cluster_version = "1.32"
8
9  cluster_endpoint_public_access  = true
10  cluster_endpoint_private_access = true
11  # Restrict public access to your corporate IP ranges
12  cluster_endpoint_public_access_cidrs = var.allowed_cidr_blocks
13
14  vpc_id     = module.vpc.vpc_id
15  subnet_ids = module.vpc.private_subnets
16
17  # Enable EKS managed addons (see addons.tf)
18  # **Development/learning use**: `most_recent = true` is convenient but not recommended for production. Pin to specific versions and test upgrades explicitly. See [terraform-eks-infrastructure-as-code](/blog/terraform-eks-infrastructure-as-code) for the versioned approach.
19  cluster_addons = {
20    vpc-cni = {
21      most_recent              = true
22      before_compute           = true  # Install before node groups
23      configuration_values     = jsonencode({
24        env = {
25          ENABLE_PREFIX_DELEGATION = "true"
26          WARM_PREFIX_TARGET       = "1"
27        }
28      })
29    }
30    coredns = {
31      most_recent = true
32    }
33    kube-proxy = {
34      most_recent = true
35    }
36    aws-ebs-csi-driver = {
37      most_recent              = true
38      service_account_role_arn = module.ebs_csi_irsa.iam_role_arn
39    }
40    eks-pod-identity-agent = {
41      most_recent = true
42    }
43  }
44
45  # Enable IRSA (OIDC provider for the cluster)
46  enable_irsa = true
47
48  # Cluster access entry for the Terraform caller
49  enable_cluster_creator_admin_permissions = true
50
51  # CloudWatch logging
52  cluster_enabled_log_types = ["api", "audit", "authenticator"]
53}

Managed Node Groups

hcl
1# main.tf (continued — add to module "eks")
2
3  eks_managed_node_groups = {
4    # System node group: on-demand, for cluster-critical DaemonSets and system pods
5    system = {
6      name = "${var.cluster_name}-system"
7
8      instance_types = ["m5.xlarge"]
9      capacity_type  = "ON_DEMAND"
10
11      min_size     = 2
12      max_size     = 4
13      desired_size = 2
14
15      labels = {
16        role = "system"
17      }
18
19      taints = {
20        # Reserve this node group for system workloads
21        dedicated = {
22          key    = "dedicated"
23          value  = "system"
24          effect = "NO_SCHEDULE"
25        }
26      }
27
28      # Enable prefix delegation max-pods
29      # **Warning**: `bootstrap_extra_args` is for Amazon Linux 2 (AL2) only. It is silently ignored on Amazon Linux 2023 (AL2023), which is the default for EKS 1.32+. For AL2023, configure kubelet settings via the nodeadm `KubeletConfiguration` in your launch template's `userData`.
30      bootstrap_extra_args = "--use-max-pods false --kubelet-extra-args '--max-pods=110'"
31    }
32
33    # Application node group: mix of spot and on-demand
34    application = {
35      name = "${var.cluster_name}-app"
36
37      instance_types = ["m5.2xlarge", "m5a.2xlarge", "m5n.2xlarge", "m4.2xlarge"]
38      capacity_type  = "SPOT"
39
40      min_size     = 3
41      max_size     = 50
42      desired_size = 6
43
44      labels = {
45        role = "application"
46      }
47
48      # Spot instance interruption handling
49      taints = {}
50
51      # Placement group for reduced inter-node latency
52      # placement_group = "..."
53
54      # **Warning**: `bootstrap_extra_args` is for Amazon Linux 2 (AL2) only. It is silently ignored on Amazon Linux 2023 (AL2023), which is the default for EKS 1.32+. For AL2023, configure kubelet settings via the nodeadm `KubeletConfiguration` in your launch template's `userData`.
55      bootstrap_extra_args = "--use-max-pods false --kubelet-extra-args '--max-pods=250'"
56    }
57  }

IAM for EBS CSI Driver and Pod Identity

hcl
1# iam.tf
2
3# EBS CSI Driver — uses IRSA (OIDC federation)
4module "ebs_csi_irsa" {
5  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
6  version = "~> 5.0"
7
8  role_name             = "${var.cluster_name}-ebs-csi-driver"
9  attach_ebs_csi_policy = true
10
11  oidc_providers = {
12    main = {
13      provider_arn               = module.eks.oidc_provider_arn
14      namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
15    }
16  }
17}
18
19# AWS Load Balancer Controller — Pod Identity association
20resource "aws_iam_role" "aws_lbc" {
21  name = "${var.cluster_name}-aws-lbc"
22
23  assume_role_policy = jsonencode({
24    Version = "2012-10-17"
25    Statement = [{
26      Effect = "Allow"
27      Principal = {
28        Service = "pods.eks.amazonaws.com"
29      }
30      Action = [
31        "sts:AssumeRole",
32        "sts:TagSession"
33      ]
34    }]
35  })
36}
37
38resource "aws_iam_role_policy_attachment" "aws_lbc" {
39  role       = aws_iam_role.aws_lbc.name
40  policy_arn = aws_iam_policy.aws_lbc.arn
41}
42
43resource "aws_iam_policy" "aws_lbc" {
44  name   = "${var.cluster_name}-aws-lbc"
45  policy = file("${path.module}/policies/aws-load-balancer-controller-policy.json")
46}
47
48resource "aws_eks_pod_identity_association" "aws_lbc" {
49  cluster_name    = module.eks.cluster_name
50  namespace       = "kube-system"
51  service_account = "aws-load-balancer-controller"
52  role_arn        = aws_iam_role.aws_lbc.arn
53}

Karpenter Setup

hcl
1# addons.tf
2
3# Karpenter IAM role via Pod Identity
4resource "aws_iam_role" "karpenter_controller" {
5  name = "${var.cluster_name}-karpenter-controller"
6
7  assume_role_policy = jsonencode({
8    Version = "2012-10-17"
9    Statement = [{
10      Effect    = "Allow"
11      Principal = { Service = "pods.eks.amazonaws.com" }
12      Action    = ["sts:AssumeRole", "sts:TagSession"]
13    }]
14  })
15}
16
17resource "aws_eks_pod_identity_association" "karpenter" {
18  cluster_name    = module.eks.cluster_name
19  namespace       = "karpenter"
20  service_account = "karpenter"
21  role_arn        = aws_iam_role.karpenter_controller.arn
22}
23
24# Karpenter node IAM role (nodes launched by Karpenter assume this role)
25module "karpenter_node_role" {
26  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
27  version = "~> 5.0"
28
29  role_name = "${var.cluster_name}-karpenter-node"
30  # ...attach AmazonEKSWorkerNodePolicy, AmazonEC2ContainerRegistryReadOnly, etc.
31}
32
33# Allow Karpenter nodes to join the cluster
34resource "aws_eks_access_entry" "karpenter_nodes" {
35  cluster_name  = module.eks.cluster_name
36  principal_arn = module.karpenter_node_role.iam_role_arn
37  type          = "EC2_LINUX"
38}
39
40# Install Karpenter via Helm
41resource "helm_release" "karpenter" {
42  namespace        = "karpenter"
43  create_namespace = true
44  name             = "karpenter"
45  repository       = "oci://public.ecr.aws/karpenter"
46  chart            = "karpenter"
47  version          = "1.2.1"
48
49  values = [
50    yamlencode({
51      settings = {
52        clusterName       = module.eks.cluster_name
53        clusterEndpoint   = module.eks.cluster_endpoint
54        interruptionQueue = aws_sqs_queue.karpenter_interruption.name
55      }
56      serviceAccount = {
57        annotations = {
58          # Pod Identity and IRSA are mutually exclusive — remove this annotation when using Pod Identity
59          # "eks.amazonaws.com/role-arn" = aws_iam_role.karpenter_controller.arn
60        }
61      }
62    })
63  ]
64
65  depends_on = [module.eks]
66}

Variables and Outputs

hcl
1# variables.tf
2variable "cluster_name" {
3  type    = string
4  default = "production"
5}
6
7variable "aws_region" {
8  type    = string
9  default = "us-east-1"
10}
11
12variable "environment" {
13  type    = string
14  default = "production"
15}
16
17variable "allowed_cidr_blocks" {
18  type    = list(string)
19  default = ["0.0.0.0/0"]   # Restrict to VPN/office CIDRs in production
20}
21
22# outputs.tf
23output "cluster_name" {
24  value = module.eks.cluster_name
25}
26
27output "cluster_endpoint" {
28  value     = module.eks.cluster_endpoint
29  sensitive = true
30}
31
32output "configure_kubectl" {
33  value = "aws eks update-kubeconfig --region ${var.aws_region} --name ${var.cluster_name}"
34}
35
36output "oidc_provider_arn" {
37  value = module.eks.oidc_provider_arn
38}

Applying the Infrastructure

bash
1# Initialise and plan
2terraform init
3terraform plan -out=tfplan
4
5# Review the plan carefully — EKS changes can be destructive
6# (e.g., changing cluster_version triggers an in-place upgrade)
7terraform apply tfplan
8
9# Configure kubectl
10aws eks update-kubeconfig --region us-east-1 --name production
11
12# Verify
13kubectl get nodes
14kubectl get pods -A

For subsequent changes, always use terraform plan before apply. Changes to cluster_version trigger an EKS control plane upgrade. Changes to managed node group AMI or configuration trigger a rolling node replacement.


Frequently Asked Questions

Should I use the terraform-aws-eks module or write EKS resources directly?

The module abstracts significant complexity (IAM role relationships, security group rules, node group bootstrap scripts) that's easy to get wrong when writing directly. Use the module unless you have requirements it can't satisfy. The module is actively maintained by the Terraform AWS Modules team and tracks EKS API changes. For one-off customisations, the module exposes escape-hatch variables that let you pass arbitrary Terraform resource arguments.

How do I manage multiple EKS clusters across environments?

Structure your Terraform code as reusable modules that accept environment-specific variables. Use Terraform workspaces or separate state files per environment (separate S3 keys). A common pattern: a modules/eks-cluster/ reusable module, with environments/production/ and environments/staging/ directories that instantiate the module with environment-specific variables. See the Platform Engineering IDP patterns for how this fits into a multi-cluster fleet.

How do I upgrade the EKS cluster version with Terraform?

hcl
# Change cluster_version to the target version
cluster_version = "1.33"   # Was "1.32"

Run terraform plan — Terraform shows the cluster upgrade as an in-place update. terraform apply triggers the EKS control plane upgrade. After the control plane upgrades, update node groups by changing the AMI release version (or let them auto-update). For a complete upgrade procedure including pre-upgrade checks, see Kubernetes Cluster Upgrades: Zero Downtime Strategy.


For a getting-started approach with a recommended multi-state Terraform structure across environments, see Terraform for Kubernetes: Managing EKS with Infrastructure as Code. For the GitOps tooling that deploys workloads onto the EKS cluster this Terraform creates, see Flux CD: GitOps for Kubernetes or GitOps with Argo CD. For EKS networking configuration (VPC CNI, IP exhaustion, Pod Identity), see EKS Networking Deep Dive.

OpenTofu compatibility: All examples in this post are compatible with OpenTofu, the open-source Terraform fork maintained by the Linux Foundation.

Building a production EKS platform with Terraform? Talk to us at Coding Protocols — we help platform teams design Terraform module structures that scale across clusters, environments, and teams.

Related Topics

Terraform
EKS
AWS
IaC
Kubernetes
Platform Engineering
DevOps

Read Next