Provisioning an EKS Cluster with Terraform from Scratch
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.
- Step 1: Project Structure
- Step 2: versions.tf — Provider Pins
- Step 3: variables.tf
- Step 4: main.tf — AWS Provider
- Step 5: vpc.tf — Network Foundation
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)
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
mkdir eks-cluster && cd eks-cluster
# Create files
touch main.tf vpc.tf eks.tf outputs.tf variables.tf versions.tfStep 2: versions.tf — Provider Pins
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
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
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
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
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
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
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 tfplanApplication takes 12–20 minutes — most of the time is the EKS control plane coming up.
Step 9: Connect kubectl
$(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.xStep 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).
OIDC_PROVIDER_ARN=$(terraform output -raw oidc_provider_arn)
OIDC_PROVIDER_URL=$(terraform output -raw oidc_provider_url)Tear Down
# Scale down node group first (faster)
terraform destroy -target=aws_eks_node_group.main
# Then destroy everything else
terraform destroyNAT 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
- AWS EKS User Guide — Official AWS docs for EKS: cluster creation, node groups, add-ons, and networking
- Terraform AWS EKS Module — The community-standard Terraform module for EKS with managed node groups and Fargate support
- Terraform AWS Provider — Official AWS provider docs for all EKS, VPC, and IAM resources
- Amazon EKS Best Practices Guide — AWS EKS team's comprehensive best practices for security, networking, and reliability
- EKS Anywhere — Running EKS on-premises with the same tooling and API as cloud EKS
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.