Step-by-Step Guide: Creating an Amazon EKS Cluster Using Terraform



This content originally appeared on DEV Community and was authored by Kelechi Edeh

Provisioning infrastructure manually is not only time-consuming but error-prone. As cloud environments scale and evolve, the need for consistency, automation, and repeatability becomes essential — and that’s where Terraform shines.

What is Terraform?

Terraform is an open-source Infrastructure as Code (IaC) tool developed by HashiCorp. It allows you to define, provision, and manage cloud infrastructure using declarative configuration files. Rather than clicking through web consoles, Terraform empowers you to codify your infrastructure and manage it just like your application code with versioning, collaboration, and automation.

Why Use Terraform for AWS EKS?

Amazon Elastic Kubernetes Service (EKS) is a managed Kubernetes service that simplifies running Kubernetes on AWS without the operational overhead of managing the control plane.

Provisioning EKS manually can be complex due to the number of components involved (VPCs, subnets, IAM roles, node groups, etc.). Terraform removes this complexity by:

  • Enabling repeatable and auditable deployments.
  • Simplifying dependency management between AWS resources.
  • Integrating with CI/CD pipelines for automated infrastructure changes.

Prerequisites

Before creating an EKS cluster, ensure you have:

  • An AWS account and credentials configured locally
  • Terraform installed (terraform -v)
  • AWS CLI installed and configured (aws configure)
  • Basic knowledge of Terraform syntax

Project Structure

I organized my terraform code into modules for reusability and clarity
├── backend
│ ├── main.tf
│ ├── output.tf
│ └── provider.tf
└── modules
├── eks
│ ├── 01_iam-cluster.tf
│ ├── 02_eks-cluster.tf
│ ├── 03_iam-node.tf
│ ├── 04_eks-node.tf
│ └── variables.tf
└── vpc
├── 01_vpc.tf
├── 02_igw.tf
├── 03_subnets.tf
├── 04_nat.tf
├── 05_routes.tf
└── output.tf

Step-by-Step: Creating the EKS Cluster

1. Configure the AWS Provider
Before you begin provisioning resources, you need to tell Terraform which cloud provider to work with. In this case, we’ll be using AWS, and Terraform needs to know which region to deploy your infrastructure into.

File: backend/provider.tf

# Configure the AWS provider
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

# Configure the AWS Provider
provider "aws" {
  region = "us-east-1"
}

Traditionally, when using S3 as a backend for storing Terraform state files, DynamoDB is used alongside it to manage state locking preventing multiple people or processes from making conflicting changes at the same time.

However, starting with Terraform AWS Provider v5.20.0, S3 now supports native state locking which means you no longer need to provision and manage a separate DynamoDB table just for locking. For this project, i implement the native state locking

File: backend/provider.tf

#locking terrform state file to s3
resource "aws_s3_bucket" "terraform_state" {
  bucket = "terraform-state-bucker12345"

  lifecycle {
    prevent_destroy = false
  }
}

terraform {  
  backend "s3" {  
    bucket       = "terraform-state-bucker12345"  
    key          = "dev/terraform-state-file"  
    region       = "us-east-1"  
    encrypt      = true  
    //use_lockfile = true  #S3 native locking
  }  
}

2. Define Your VPC Module
The VPC module sets up networking for your EKS cluster — including private/public subnets and route tables.

modules/vpc/vpc.tf

# Create a VPC
resource "aws_vpc" "eks-vpc" {
  cidr_block = "192.168.0.0/16"
}

modules/vpc/igw.tf

# Create an internet gateway
resource "aws_internet_gateway" "eks-igw" {
  vpc_id = aws_vpc.eks-vpc.id

  tags = {
    Name = "dev-eks"
  }
}

modules/vpc/subnets.tf

# Create private subnet
resource "aws_subnet" "private-1a" {
  vpc_id     = aws_vpc.eks-vpc.id
  cidr_block = "192.168.0.0/19"
  availability_zone = "us-east-1a"

  tags = {
    Name = "eks-private-subnet-1a"
     "kubernetes.io/role/internal-elb" = "1"
    "kubernetes.io/cluster/eks-cluster"      = "owned"
  }

}

resource "aws_subnet" "private-1b" {
  vpc_id     = aws_vpc.eks-vpc.id
  cidr_block = "192.168.32.0/19"
  availability_zone = "us-east-1b"

  tags = {
    Name = "eks-private-subnet-1b"
     "kubernetes.io/role/internal-elb" = "1"
    "kubernetes.io/cluster/eks-cluster"      = "owned"
  }

}


# Create public subnet
resource "aws_subnet" "public-1a" {
  vpc_id     = aws_vpc.eks-vpc.id
  cidr_block = "192.168.64.0/19"
  availability_zone = "us-east-1a"

  tags = {
    Name = "eks-public-subnet-1a"
     "kubernetes.io/role/elb" = "1"
    "kubernetes.io/cluster/eks-cluster"      = "owned"
  }
}

resource "aws_subnet" "public-1b" {
  vpc_id     = aws_vpc.eks-vpc.id
  cidr_block = "192.168.96.0/19"
  availability_zone = "us-east-1b"

  tags = {
    Name = "eks-public-subnet-1b"
     "kubernetes.io/role/elb" = "1"
    "kubernetes.io/cluster/eks-cluster"      = "owned"
  }
}

modules/vpc/nat.tf

# Create elastic IP
resource "aws_eip" "eks-eip" {
  domain   = "vpc"
}

resource "aws_nat_gateway" "eks-nat" {
  allocation_id = aws_eip.eks-eip.id
  subnet_id     = aws_subnet.public-1a.id

  tags = {
    Name = "eks-NAT"
  }

  # To ensure proper ordering, it is recommended to add an explicit dependency
  # on the Internet Gateway for the VPC.
  depends_on = [aws_internet_gateway.eks-igw]
}

modules/vpc/routes.tf

#Create public route table
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.eks-vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.eks-igw.id
  }


  tags = {
    Name = "public"
  }
}


#Create private route table
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.eks-vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.eks-nat.id
  }

  tags = {
    Name = "private"
  }
}

# routing table association

resource "aws_main_route_table_association" "private-1a" {
  vpc_id = aws_vpc.eks-vpc.id
  #subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private.id
}
resource "aws_main_route_table_association" "private-1b" {
  vpc_id = aws_vpc.eks-vpc.id
  #subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private.id
}
resource "aws_main_route_table_association" "public-1a" {
  vpc_id = aws_vpc.eks-vpc.id
  #subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}
resource "aws_main_route_table_association" "public-1b" {
  vpc_id = aws_vpc.eks-vpc.id
  #subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

modules/vpc/output.tf

output "public_subnet_ids" {
  value = [
    aws_subnet.public-1a.id,
    aws_subnet.public-1b.id

  ]
}

output "private_subnet_ids" {
  value = [
    aws_subnet.private-1a.id,
    aws_subnet.private-1b.id
  ]
}

2. Create EKS Module
In modules/eks, I defined:

  • IAM roles for control plane and nodes
  • EKS cluster resource
  • EKS node group resource

modules/eks/iam-cluster.tf

# Create iam role
resource "aws_iam_role" "eks-cluster-role" {
  name = "eks-cluster-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid    = ""
        Principal = {
          Service = "eks.amazonaws.com"
        }
      },
    ]
  })

  tags = {
    Version = "2012-10-17"
  }
}

# Attach policy to iam role

resource "aws_iam_role_policy_attachment" "eks-cluster" {
  role      = aws_iam_role.eks-cluster-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}

modules/eks/eks-cluster.tf

# Create EKS cluster
resource "aws_eks_cluster" "eks-cluster" {
  name = "my-eks-cluster"

  access_config {
    authentication_mode = "API"
  }

  role_arn = aws_iam_role.eks-cluster-role.arn
  version  = "1.31"

  vpc_config {
    subnet_ids = flatten([
      var.public_subnet_ids,
      var.private_subnet_ids
    ])
  }

  depends_on = [
    aws_iam_role_policy_attachment.eks-cluster,
  ]
}

modules/eks/iam-nodes.tf

# Create iam role for node group
resource "aws_iam_role" "eks-node-role" {
  name = "eks-node-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid    = ""
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      },
    ]
  })

  tags = {
    Version = "2012-10-17"
  }
}

# Attach policy to iam role

resource "aws_iam_role_policy_attachment" "eks-node-policy" {
  role      = aws_iam_role.eks-node-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}
resource "aws_iam_role_policy_attachment" "eks-container-registry-policy" {
  role      = aws_iam_role.eks-node-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
resource "aws_iam_role_policy_attachment" "eks-cni-policy" {
  role      = aws_iam_role.eks-node-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}

modules/eks/eks-nodes.tf

# Create node group for eks cluster
resource "aws_eks_node_group" "eks-node-group" {
  cluster_name    = aws_eks_cluster.eks-cluster.name
  node_group_name = "my-eks-node-group"
  node_role_arn   = aws_iam_role.eks-node-role.arn
  subnet_ids      = var.private_subnet_ids

  scaling_config {
    desired_size = 1
    max_size     = 2
    min_size     = 1
  }

  update_config {
    max_unavailable = 1
  }

  depends_on = [
    aws_iam_role_policy_attachment.eks-node-policy,
    aws_iam_role_policy_attachment.eks-container-registry-policy,
    aws_iam_role_policy_attachment.eks-cni-policy

  ]
}

modules/eks/variables.tf

variable "public_subnet_ids" {
  description = "List of subnet IDs to use for the EKS cluster"
  type        = list(string)
}
variable "private_subnet_ids" {
  description = "List of subnet IDs to use for the EKS cluster"
  type        = list(string)
}


This content originally appeared on DEV Community and was authored by Kelechi Edeh