Cloud Engineering

Provisioning an EKS Cluster with Terraform from Scratch

Intermediate60 min to complete14 min readApril 22, 2026Updated August 19, 2026

Quick answer

Build a production-ready EKS cluster with Terraform: VPC with private subnets, managed node groups, IRSA for pod IAM, and OIDC provider — all in reproducible, reviewable infrastructure code.

intermediate · 60 min

Before you begin

  • Terraform >= 1.6 installed
  • AWS CLI configured with sufficient IAM permissions
  • kubectl installed
  • Basic Terraform knowledge (init, plan, apply)
AWS
EKS
Terraform
Kubernetes
Infrastructure as Code

Creating an EKS cluster through the AWS console is fine for learning. Doing it with Terraform means you can reproduce it, review changes before applying, and destroy it cleanly. This tutorial builds a cluster you'd actually run in production.

What You'll Build

VPC
├── 3 public subnets (one per AZ) — load balancers
├── 3 private subnets (one per AZ) — EKS nodes
└── NAT Gateway — outbound internet from private subnets

EKS Cluster (Kubernetes 1.32)
├── Managed node group — 2–10 nodes, t3.medium
├── OIDC provider — enables IRSA for pods
├── aws-vpc-cni add-on — pod networking
├── coredns add-on — cluster DNS
└── kube-proxy add-on — service networking

Step 1: Project Structure

bash
mkdir eks-cluster && cd eks-cluster

# Create files
touch main.tf vpc.tf eks.tf outputs.tf variables.tf versions.tf

Step 2: versions.tf — Provider Pins

hcl
1# versions.tf
2terraform {
3  required_version = ">= 1.6"
4
5  required_providers {
6    aws = {
7      source  = "hashicorp/aws"
8      version = "~> 5.0"
9    }
10    # kubernetes provider: add here only if you plan to create
11    # Kubernetes resources via Terraform (e.g., namespaces, ConfigMaps).
12    # Requires cluster configuration (endpoint + ca_cert + token) which
13    # creates a chicken-and-egg dependency — the cluster must exist first.
14    # Uncomment if needed:
15    # kubernetes = {
16    #   source  = "hashicorp/kubernetes"
17    #   version = "~> 2.25"
18    # }
19  }
20
21  # Remote state — replace with your bucket
22  backend "s3" {
23    bucket = "my-terraform-state-bucket"
24    key    = "eks/terraform.tfstate"
25    region = "ap-south-1"
26  }
27}

Step 3: variables.tf

hcl
1# variables.tf
2variable "cluster_name" {
3  description = "EKS cluster name"
4  type        = string
5  default     = "my-cluster"
6}
7
8variable "aws_region" {
9  description = "AWS region"
10  type        = string
11  default     = "ap-south-1"
12}
13
14variable "kubernetes_version" {
15  description = "Kubernetes version"
16  type        = string
17  default     = "1.32"
18}
19
20variable "node_instance_type" {
21  description = "EC2 instance type for worker nodes"
22  type        = string
23  default     = "t3.medium"
24}
25
26variable "node_min_size" {
27  type    = number
28  default = 2
29}
30
31variable "node_max_size" {
32  type    = number
33  default = 10
34}
35
36variable "node_desired_size" {
37  type    = number
38  default = 3
39}

Step 4: main.tf — AWS Provider

hcl
1# main.tf
2provider "aws" {
3  region = var.aws_region
4
5  default_tags {
6    tags = {
7      ManagedBy   = "terraform"
8      Cluster     = var.cluster_name
9      Environment = "production"
10    }
11  }
12}
13
14# Data sources
15data "aws_availability_zones" "available" {
16  state = "available"
17}
18
19data "aws_caller_identity" "current" {}

Step 5: vpc.tf — Network Foundation

hcl
1# vpc.tf
2locals {
3  azs            = slice(data.aws_availability_zones.available.names, 0, 3)
4  vpc_cidr       = "10.0.0.0/16"
5  public_cidrs   = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
6  private_cidrs  = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
7}
8
9# VPC
10resource "aws_vpc" "main" {
11  cidr_block           = local.vpc_cidr
12  enable_dns_hostnames = true
13  enable_dns_support   = true
14
15  tags = {
16    Name = "${var.cluster_name}-vpc"
17    # Required for EKS to discover the VPC
18    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
19  }
20}
21
22# Internet Gateway
23resource "aws_internet_gateway" "main" {
24  vpc_id = aws_vpc.main.id
25  tags   = { Name = "${var.cluster_name}-igw" }
26}
27
28# Public subnets — for load balancers
29resource "aws_subnet" "public" {
30  count             = 3
31  vpc_id            = aws_vpc.main.id
32  cidr_block        = local.public_cidrs[count.index]
33  availability_zone = local.azs[count.index]
34
35  map_public_ip_on_launch = true
36
37  tags = {
38    Name                     = "${var.cluster_name}-public-${count.index + 1}"
39    "kubernetes.io/role/elb" = "1"   # Required for AWS Load Balancer Controller
40    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
41  }
42}
43
44# Private subnets — for EKS nodes
45resource "aws_subnet" "private" {
46  count             = 3
47  vpc_id            = aws_vpc.main.id
48  cidr_block        = local.private_cidrs[count.index]
49  availability_zone = local.azs[count.index]
50
51  tags = {
52    Name                              = "${var.cluster_name}-private-${count.index + 1}"
53    "kubernetes.io/role/internal-elb" = "1"   # For internal load balancers
54    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
55  }
56}
57
58# Elastic IPs for NAT Gateways
59resource "aws_eip" "nat" {
60  count  = 3
61  domain = "vpc"
62  tags   = { Name = "${var.cluster_name}-nat-eip-${count.index + 1}" }
63}
64
65# NAT Gateways — one per AZ for HA
66resource "aws_nat_gateway" "main" {
67  count         = 3
68  allocation_id = aws_eip.nat[count.index].id
69  subnet_id     = aws_subnet.public[count.index].id
70  tags          = { Name = "${var.cluster_name}-nat-${count.index + 1}" }
71  depends_on    = [aws_internet_gateway.main]
72}
73
74# Route tables
75resource "aws_route_table" "public" {
76  vpc_id = aws_vpc.main.id
77
78  route {
79    cidr_block = "0.0.0.0/0"
80    gateway_id = aws_internet_gateway.main.id
81  }
82
83  tags = { Name = "${var.cluster_name}-public-rt" }
84}
85
86resource "aws_route_table" "private" {
87  count  = 3
88  vpc_id = aws_vpc.main.id
89
90  route {
91    cidr_block     = "0.0.0.0/0"
92    nat_gateway_id = aws_nat_gateway.main[count.index].id
93  }
94
95  tags = { Name = "${var.cluster_name}-private-rt-${count.index + 1}" }
96}
97
98# Route table associations
99resource "aws_route_table_association" "public" {
100  count          = 3
101  subnet_id      = aws_subnet.public[count.index].id
102  route_table_id = aws_route_table.public.id
103}
104
105resource "aws_route_table_association" "private" {
106  count          = 3
107  subnet_id      = aws_subnet.private[count.index].id
108  route_table_id = aws_route_table.private[count.index].id
109}

Step 6: eks.tf — Cluster and Node Groups

hcl
1# eks.tf
2
3# IAM role for the EKS control plane
4resource "aws_iam_role" "eks_cluster" {
5  name = "${var.cluster_name}-cluster-role"
6
7  assume_role_policy = jsonencode({
8    Version = "2012-10-17"
9    Statement = [{
10      Action    = "sts:AssumeRole"
11      Effect    = "Allow"
12      Principal = { Service = "eks.amazonaws.com" }
13    }]
14  })
15}
16
17resource "aws_iam_role_policy_attachment" "eks_cluster_policy" {
18  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
19  role       = aws_iam_role.eks_cluster.name
20}
21
22# Security group for the cluster API endpoint
23resource "aws_security_group" "cluster" {
24  name        = "${var.cluster_name}-cluster-sg"
25  description = "EKS cluster security group"
26  vpc_id      = aws_vpc.main.id
27
28  egress {
29    from_port   = 0
30    to_port     = 0
31    protocol    = "-1"
32    cidr_blocks = ["0.0.0.0/0"]
33  }
34
35  tags = { Name = "${var.cluster_name}-cluster-sg" }
36}
37
38# EKS Cluster
39resource "aws_eks_cluster" "main" {
40  name     = var.cluster_name
41  role_arn = aws_iam_role.eks_cluster.arn
42  version  = var.kubernetes_version
43
44  vpc_config {
45    subnet_ids              = concat(aws_subnet.private[*].id, aws_subnet.public[*].id)
46    security_group_ids      = [aws_security_group.cluster.id]
47    endpoint_private_access = true
48    endpoint_public_access  = true   # Set to false and use VPN for production
49    public_access_cidrs     = ["0.0.0.0/0"]   # Restrict to your office IP for production
50  }
51
52  enabled_cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
53
54  depends_on = [aws_iam_role_policy_attachment.eks_cluster_policy]
55}
56
57# OIDC Provider — enables IRSA (IAM Roles for Service Accounts)
58resource "aws_iam_openid_connect_provider" "eks" {
59  client_id_list  = ["sts.amazonaws.com"]
60  # Hardcoded root CA thumbprint for EKS OIDC issuer — use tls_certificate data source only if your
61  # cluster uses a custom OIDC issuer. For standard EKS, this value is stable.
62  thumbprint_list = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"]
63  url             = aws_eks_cluster.main.identity[0].oidc[0].issuer
64}
65
66# IAM role for worker nodes
67resource "aws_iam_role" "node_group" {
68  name = "${var.cluster_name}-node-group-role"
69
70  assume_role_policy = jsonencode({
71    Version = "2012-10-17"
72    Statement = [{
73      Action    = "sts:AssumeRole"
74      Effect    = "Allow"
75      Principal = { Service = "ec2.amazonaws.com" }
76    }]
77  })
78}
79
80resource "aws_iam_role_policy_attachment" "node_group_policies" {
81  for_each = toset([
82    "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
83    "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy",
84    "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
85  ])
86
87  policy_arn = each.value
88  role       = aws_iam_role.node_group.name
89}
90
91# Managed Node Group
92resource "aws_eks_node_group" "main" {
93  cluster_name    = aws_eks_cluster.main.name
94  node_group_name = "${var.cluster_name}-main"
95  node_role_arn   = aws_iam_role.node_group.arn
96  subnet_ids      = aws_subnet.private[*].id
97
98  instance_types = [var.node_instance_type]
99  ami_type       = "AL2023_x86_64_STANDARD"
100  capacity_type  = "ON_DEMAND"
101
102  scaling_config {
103    desired_size = var.node_desired_size
104    min_size     = var.node_min_size
105    max_size     = var.node_max_size
106  }
107
108  update_config {
109    max_unavailable = 1
110  }
111
112  labels = {
113    role = "general"
114  }
115
116  depends_on = [aws_iam_role_policy_attachment.node_group_policies]
117
118  lifecycle {
119    ignore_changes = [scaling_config[0].desired_size]  # Let Cluster Autoscaler manage this
120  }
121}
122
123# Core EKS Add-ons
124resource "aws_eks_addon" "vpc_cni" {
125  cluster_name             = aws_eks_cluster.main.name
126  addon_name               = "vpc-cni"
127  resolve_conflicts_on_update = "OVERWRITE"
128  depends_on               = [aws_eks_node_group.main]
129}
130
131resource "aws_eks_addon" "coredns" {
132  cluster_name             = aws_eks_cluster.main.name
133  addon_name               = "coredns"
134  resolve_conflicts_on_update = "OVERWRITE"
135  depends_on               = [aws_eks_node_group.main]
136}
137
138resource "aws_eks_addon" "kube_proxy" {
139  cluster_name             = aws_eks_cluster.main.name
140  addon_name               = "kube-proxy"
141  resolve_conflicts_on_update = "OVERWRITE"
142  depends_on               = [aws_eks_node_group.main]
143}

Step 7: outputs.tf

hcl
1# outputs.tf
2output "cluster_name" {
3  value = aws_eks_cluster.main.name
4}
5
6output "cluster_endpoint" {
7  value = aws_eks_cluster.main.endpoint
8}
9
10output "cluster_certificate_authority" {
11  value     = aws_eks_cluster.main.certificate_authority[0].data
12  sensitive = true
13}
14
15output "oidc_provider_arn" {
16  value = aws_iam_openid_connect_provider.eks.arn
17}
18
19output "oidc_provider_url" {
20  value = replace(aws_eks_cluster.main.identity[0].oidc[0].issuer, "https://", "")
21}
22
23output "configure_kubectl" {
24  value = "aws eks update-kubeconfig --region ${var.aws_region} --name ${var.cluster_name}"
25}

Step 8: Apply

bash
1terraform init
2
3# Check the plan before applying
4terraform plan -out=tfplan
5
6# Review: should see ~50 resources to create
7# Apply
8terraform apply tfplan

Application takes 12–20 minutes — most of the time is the EKS control plane coming up.

Step 9: Connect kubectl

bash
$(terraform output -raw configure_kubectl)

kubectl get nodes
# NAME                          STATUS   ROLES    AGE   VERSION
# ip-10-0-11-xxx.ap-south-1.compute.internal   Ready    <none>   5m    v1.32.x

Step 10: Use the OIDC Provider for IRSA

The OIDC provider ARN is in terraform output oidc_provider_arn. Use it to create IAM roles for pods (see the AWS IRSA tutorial for the full workflow).

bash
OIDC_PROVIDER_ARN=$(terraform output -raw oidc_provider_arn)
OIDC_PROVIDER_URL=$(terraform output -raw oidc_provider_url)

Tear Down

bash
# Scale down node group first (faster)
terraform destroy -target=aws_eks_node_group.main

# Then destroy everything else
terraform destroy

NAT Gateways are expensive — make sure you destroy the cluster when you're done with it.

Frequently Asked Questions

Why pin provider versions?

Because an unpinned provider changes under you. A new major version can alter default behaviour or resource schemas, so an apply that worked yesterday plans a replacement today — and on an EKS cluster a replacement is not a routine change. Pin with a constraint that allows patches, and upgrade deliberately with a plan you have read.

Should the VPC and the cluster be in the same state file?

Separate them once the environment is real. The VPC changes rarely and the cluster changes often, and a single state means every cluster change plans against network resources too. Separate state limits the blast radius of a mistake and lets different people own each layer, at the cost of passing outputs between them.

How long does an EKS cluster take to create and destroy?

Cluster creation is typically ten to fifteen minutes before node groups even begin, and destroy is comparable. Set generous timeouts and do not interrupt a partially complete apply — a cancelled create leaves resources Terraform knows about but has not finished, which is more work to reconcile than waiting.

Why does terraform destroy fail on the VPC?

Almost always because something created outside Terraform is still attached — a load balancer provisioned by a Kubernetes Service, or an ENI from a controller. Terraform did not create them so it does not destroy them, and they block the VPC. Delete Kubernetes Services of type LoadBalancer before destroying the infrastructure that hosts them.

Official References

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.