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.

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:
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:
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-1VPC
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
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
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
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
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
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
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 -AFor 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?
# 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.


