Amazon EKS — Terraform Complete Working Example
This is a fully self-contained Terraform configuration that creates a production-grade EKS cluster — VPC, EKS, node groups, add-ons, and IRSA — in a single apply.
Single-File Quick Start
For learning and quick setups, everything in one file:
# main.tf — Complete EKS Cluster
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Environment = "dev"
ManagedBy = "terraform"
}
}
}
# ── Variables ──
variable "cluster_name" {
default = "dev-eks"
}
variable "cluster_version" {
default = "1.31"
}
# ── Data Sources ──
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_caller_identity" "current" {}
locals {
azs = slice(data.aws_availability_zones.available.names, 0, 3)
account_id = data.aws_caller_identity.current.account_id
}
# ══════════════════════════════════════
# VPC
# ══════════════════════════════════════
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.cluster_name}-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.cluster_name}-igw" }
}
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet("10.0.0.0/16", 8, count.index + 100)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.cluster_name}-public-${local.azs[count.index]}"
"kubernetes.io/role/elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_subnet" "private" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet("10.0.0.0/16", 8, count.index + 1)
availability_zone = local.azs[count.index]
tags = {
Name = "${var.cluster_name}-private-${local.azs[count.index]}"
"kubernetes.io/role/internal-elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_eip" "nat" {
domain = "vpc"
tags = { Name = "${var.cluster_name}-nat-eip" }
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
tags = { Name = "${var.cluster_name}-nat" }
depends_on = [aws_internet_gateway.main]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "${var.cluster_name}-public-rt" }
}
resource "aws_route_table_association" "public" {
count = 3
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = { Name = "${var.cluster_name}-private-rt" }
}
resource "aws_route_table_association" "private" {
count = 3
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
# ══════════════════════════════════════
# EKS CLUSTER
# ══════════════════════════════════════
resource "aws_iam_role" "cluster" {
name = "${var.cluster_name}-cluster-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "eks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "cluster" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
role = aws_iam_role.cluster.name
}
resource "aws_eks_cluster" "main" {
name = var.cluster_name
version = var.cluster_version
role_arn = aws_iam_role.cluster.arn
vpc_config {
subnet_ids = aws_subnet.private[*].id
endpoint_private_access = true
endpoint_public_access = true
}
enabled_cluster_log_types = ["api", "audit", "authenticator"]
access_config {
authentication_mode = "API_AND_CONFIG_MAP"
bootstrap_cluster_creator_admin_permissions = true
}
depends_on = [aws_iam_role_policy_attachment.cluster]
}
# ── OIDC Provider ──
data "tls_certificate" "cluster" {
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
resource "aws_iam_openid_connect_provider" "cluster" {
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.cluster.certificates[0].sha1_fingerprint]
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
# ══════════════════════════════════════
# NODE GROUP
# ══════════════════════════════════════
resource "aws_iam_role" "node" {
name = "${var.cluster_name}-node-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "node_policies" {
for_each = toset([
"arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
"arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy",
"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
])
policy_arn = each.value
role = aws_iam_role.node.name
}
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "${var.cluster_name}-main"
node_role_arn = aws_iam_role.node.arn
subnet_ids = aws_subnet.private[*].id
instance_types = ["m6i.large"]
scaling_config {
desired_size = 2
min_size = 1
max_size = 5
}
update_config {
max_unavailable = 1
}
depends_on = [aws_iam_role_policy_attachment.node_policies]
lifecycle {
ignore_changes = [scaling_config[0].desired_size]
}
}
# ══════════════════════════════════════
# ADD-ONS
# ══════════════════════════════════════
resource "aws_eks_addon" "coredns" {
cluster_name = aws_eks_cluster.main.name
addon_name = "coredns"
resolve_conflicts_on_update = "OVERWRITE"
depends_on = [aws_eks_node_group.main]
}
resource "aws_eks_addon" "kube_proxy" {
cluster_name = aws_eks_cluster.main.name
addon_name = "kube-proxy"
resolve_conflicts_on_update = "OVERWRITE"
}
resource "aws_eks_addon" "vpc_cni" {
cluster_name = aws_eks_cluster.main.name
addon_name = "vpc-cni"
resolve_conflicts_on_update = "OVERWRITE"
}
# ── EBS CSI with IRSA ──
locals {
oidc_provider = replace(aws_eks_cluster.main.identity[0].oidc[0].issuer, "https://", "")
}
resource "aws_iam_role" "ebs_csi" {
name = "${var.cluster_name}-ebs-csi"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.cluster.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"${local.oidc_provider}:aud" = "sts.amazonaws.com"
"${local.oidc_provider}:sub" = "system:serviceaccount:kube-system:ebs-csi-controller-sa"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "ebs_csi" {
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy"
role = aws_iam_role.ebs_csi.name
}
resource "aws_eks_addon" "ebs_csi" {
cluster_name = aws_eks_cluster.main.name
addon_name = "aws-ebs-csi-driver"
resolve_conflicts_on_update = "OVERWRITE"
service_account_role_arn = aws_iam_role.ebs_csi.arn
depends_on = [aws_eks_node_group.main]
}
# ══════════════════════════════════════
# OUTPUTS
# ══════════════════════════════════════
output "cluster_name" {
value = aws_eks_cluster.main.name
}
output "cluster_endpoint" {
value = aws_eks_cluster.main.endpoint
}
output "configure_kubectl" {
value = "aws eks update-kubeconfig --name ${aws_eks_cluster.main.name} --region us-east-1"
}
Deploy the Cluster
# Initialize
terraform init
# Review plan
terraform plan
# Apply (takes ~15-20 minutes)
terraform apply
# Configure kubectl
$(terraform output -raw configure_kubectl)
# Verify
kubectl get nodes
kubectl get pods -n kube-system
Expected Cost
| Resource | Monthly Cost (approx) |
|---|---|
| EKS Control Plane | $73 |
| 2× m6i.large On-Demand | $140 |
| NAT Gateway | $32 + data |
| EBS volumes | ~$10 |
| Total | ~$255/month |
Destroy
# Destroy everything
terraform destroy
# Confirm with 'yes'
Important: Delete any manually-created resources (ALBs, EBS volumes, security groups) first, or
terraform destroymay hang.