intermediate terraform · 45 minutes ·

Deploy an EKS Cluster with Terraform

Step-by-step tutorial to provision a production-ready EKS cluster on AWS using Terraform modules with networking and node groups.

Prerequisites

  • Basic Terraform knowledge
  • AWS account with admin access
  • kubectl installed

Tools Used

terraformkubectlaws-cli
Deploy an EKS Cluster with Terraform

This tutorial walks you through provisioning a production-ready Amazon EKS cluster with Terraform. Instead of clicking through the AWS console, you describe the whole stack as code: a dedicated VPC, public and private subnets, an EKS control plane, managed node groups, and the IAM plumbing that lets pods assume AWS roles through IRSA. By the end you will have a working cluster, a configured kubectl context, and worker nodes reporting Ready.

We lean on the two community modules that the EKS team and most production shops use, terraform-aws-modules/vpc/aws and terraform-aws-modules/eks/aws. They encode a large amount of EKS-specific knowledge (subnet tagging, security group rules, OIDC provider wiring) that is tedious and error-prone to write by hand. For the underlying service behavior, the Amazon EKS user guide is the authoritative reference.

What You Will Build

By the end of this guide you will have provisioned:

  • A VPC spanning three Availability Zones with three private and three public subnets, a single shared NAT gateway, and the subnet tags EKS load balancers require.
  • An EKS control plane running Kubernetes 1.30 with both public and private API endpoints enabled.
  • A managed node group of two t3.medium instances that scales between two and four nodes.
  • An OIDC identity provider attached to the cluster so workloads can use IAM Roles for Service Accounts (IRSA).
  • Core add-ons (CoreDNS, kube-proxy, the VPC CNI, and the EBS CSI driver) installed and version-pinned.

The whole thing fits in a handful of .tf files. Total spend while it runs is roughly the control plane hourly charge plus two small EC2 instances and one NAT gateway, so remember the cleanup step at the end.

Prerequisites

Before you start, confirm the following on your workstation:

  • Terraform 1.9 or newer. Run terraform version to check. The configuration below uses required_version = ">= 1.9".
  • AWS CLI v2, authenticated against an account with permissions to create VPC, EKS, IAM, and EC2 resources. Verify with aws sts get-caller-identity.
  • kubectl 1.29 or newer, ideally within one minor version of the cluster. Check with kubectl version --client.
  • A remote state backend is recommended for any shared environment. This tutorial uses local state to keep things simple, but a short note on S3 plus DynamoDB locking appears at the end.
terraform version
aws sts get-caller-identity
kubectl version --client

Step 1: Project Layout and Providers

Create a project directory and split the configuration into a few focused files. Keeping providers, variables, modules, and outputs separate makes the stack far easier to read once it grows.

mkdir eks-cluster && cd eks-cluster
touch versions.tf variables.tf main.tf outputs.tf

Pin Terraform and the providers in versions.tf. The EKS module needs the aws, kubernetes, tls, and cloudinit providers, so declare them all up front to avoid surprise downloads mid-run.

# versions.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.31"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
    cloudinit = {
      source  = "hashicorp/cloudinit"
      version = "~> 2.3"
    }
  }
}

provider "aws" {
  region = var.region
}

Declare the inputs the rest of the stack depends on in variables.tf. Defaults keep the example runnable, but every value is overridable per environment.

# variables.tf
variable "region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-west-2"
}

variable "cluster_name" {
  description = "Name of the EKS cluster"
  type        = string
  default     = "demo-eks"
}

variable "cluster_version" {
  description = "Kubernetes control plane version"
  type        = string
  default     = "1.30"
}

variable "vpc_cidr" {
  description = "CIDR block for the cluster VPC"
  type        = string
  default     = "10.0.0.0/16"
}

A small locals block in main.tf derives the Availability Zones from the region and centralizes the tags every resource should carry. Computing AZs from a data source rather than hardcoding us-west-2a style strings keeps the configuration portable across regions.

# main.tf
data "aws_availability_zones" "available" {
  filter {
    name   = "opt-in-status"
    values = ["opt-in-not-required"]
  }
}

locals {
  azs = slice(data.aws_availability_zones.available.names, 0, 3)

  tags = {
    Project     = var.cluster_name
    Environment = "demo"
    ManagedBy   = "terraform"
  }
}

Step 2: VPC and Networking

EKS has firm expectations about networking. The control plane and worker nodes live in private subnets with outbound internet through a NAT gateway, while public subnets host internet-facing load balancers. EKS also discovers where to place load balancers through subnet tags, so getting those right matters.

Add the VPC module to main.tf. Three private and three public subnets give you one of each per AZ, which is what a highly available cluster wants.

# main.tf (continued)
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.13"

  name = "${var.cluster_name}-vpc"
  cidr = var.vpc_cidr

  azs             = local.azs
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway   = true
  single_nat_gateway   = true
  enable_dns_hostnames = true
  enable_dns_support   = true

  public_subnet_tags = {
    "kubernetes.io/role/elb" = "1"
  }

  private_subnet_tags = {
    "kubernetes.io/role/internal-elb" = "1"
  }

  tags = local.tags
}

A few things are worth calling out here. single_nat_gateway = true routes all private outbound traffic through one NAT gateway to save money in a demo. For production you usually want one NAT gateway per AZ (one_nat_gateway_per_az = true) so a single AZ outage does not cut off the rest of the cluster. The kubernetes.io/role/elb tag tells the AWS Load Balancer Controller to place public load balancers in the public subnets, and kubernetes.io/role/internal-elb does the same for internal ones in the private subnets.

The address plan also deserves a thought before you commit to it. Each /24 private subnet here gives you 251 usable IPs, and because the VPC CNI assigns every pod a real VPC address, those IPs get consumed by pods, not just nodes. On busy clusters a /24 runs out faster than you would expect, so production VPCs often use /20 or larger private subnets, or enable prefix delegation on the VPC CNI to pack more pod IPs per node. For this tutorial the /24 ranges are plenty, but size the CIDR for the pod density you expect rather than the node count.

One more reason to let the module own the VPC: it wires the route tables, internet gateway, and NAT routes together correctly and tags the VPC itself with kubernetes.io/cluster/<name> = shared. EKS and the load balancer controller read those tags during discovery, and a missing or wrong tag is one of the most common reasons load balancers fail to provision later.

Step 3: The EKS Cluster

With networking in place, add the EKS module. This single block provisions the control plane, the OIDC provider, the cluster security group, and the add-ons. We will define the node groups in the next step inside the same module block, but it helps to look at the control plane settings first.

# main.tf (continued)
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.24"

  cluster_name    = var.cluster_name
  cluster_version = var.cluster_version

  cluster_endpoint_public_access  = true
  cluster_endpoint_private_access = true

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets

  enable_cluster_creator_admin_permissions = true

  cluster_addons = {
    coredns = {
      most_recent = true
    }
    kube-proxy = {
      most_recent = true
    }
    vpc-cni = {
      most_recent = true
    }
  }

  tags = local.tags
}

cluster_endpoint_public_access = true keeps the Kubernetes API reachable from your workstation, which is convenient for a tutorial. In a locked-down environment you would set this to false and reach the API through a bastion or VPN, relying on private access only. enable_cluster_creator_admin_permissions = true grants the IAM principal running terraform apply cluster-admin via an EKS access entry, so you can run kubectl immediately without manually editing the aws-auth ConfigMap.

The cluster_addons block manages the three add-ons EKS treats as first-class: CoreDNS for in-cluster DNS, kube-proxy for service networking, and the VPC CNI that assigns pods real VPC IP addresses. Setting most_recent = true lets the module pick a compatible version for the control plane version you chose. If you prefer reproducible upgrades, pin each add-on to an explicit addon_version instead and bump it deliberately when you raise cluster_version.

It is worth understanding what this module call does behind the scenes, because the EKS abstraction hides a fair amount. Creating the cluster also creates the cluster IAM role with the AmazonEKSClusterPolicy attached, a cluster security group that controls control-plane-to-node traffic, the OIDC identity provider you will use in Step 5, and an EKS access entry for whoever runs the apply. Authorization in modern EKS uses access entries and access policies rather than the legacy aws-auth ConfigMap, which is why enable_cluster_creator_admin_permissions is enough to get you cluster-admin without any manual ConfigMap editing. If you need to grant other IAM principals access later, you add access_entries to this same module block rather than touching Kubernetes RBAC directly.

Step 4: Managed Node Groups

Managed node groups let EKS handle the lifecycle of the EC2 instances: provisioning, draining on update, and rolling replacements. Add an eks_managed_node_groups block to the same module "eks" call. Place it just above the tags line.

# main.tf -- add inside the module "eks" block
  eks_managed_node_groups = {
    default = {
      ami_type       = "AL2023_x86_64_STANDARD"
      instance_types = ["t3.medium"]
      capacity_type  = "ON_DEMAND"

      min_size     = 2
      max_size     = 4
      desired_size = 2

      labels = {
        role = "general"
      }

      update_config = {
        max_unavailable_percentage = 33
      }
    }
  }

ami_type = "AL2023_x86_64_STANDARD" selects the Amazon Linux 2023 EKS-optimized image, which is the current default for new clusters. The update_config keeps at most a third of the group unavailable during a rolling node update so workloads stay scheduled. The autoscaling range from two to four nodes pairs nicely with the Cluster Autoscaler or Karpenter later, but even without those, you can bump desired_size and reapply to scale by hand.

For mixed workloads you can declare several node groups in the same map, for example a spot group with capacity_type = "SPOT" and a larger instance type for batch jobs. Each entry becomes its own managed node group with independent scaling.

Two operational details save pain later. First, the labels you set here flow through to Kubernetes node labels, so a role = general label lets you target these nodes with nodeSelector or affinity rules from day one. Add taints to a node group when you want to reserve it for specific workloads, such as a GPU pool that only tolerated pods can land on. Second, the module attaches the three node IAM policies every worker needs, AmazonEKSWorkerNodePolicy, AmazonEC2ContainerRegistryReadOnly, and AmazonEKS_CNI_Policy, so nodes can register with the control plane, pull images from ECR, and let the CNI manage ENIs. If nodes ever fail to join, a missing one of these policies is the first thing to check.

Step 5: IAM Roles and IRSA / OIDC

IAM Roles for Service Accounts is how pods get scoped AWS permissions without baking long-lived credentials into containers. The EKS module already created the OIDC identity provider for you. The remaining work is creating an IAM role that trusts that provider and binding it to a Kubernetes service account.

The cleanest way to build the trust policy is the IRSA submodule, which handles the OIDC condition keys correctly. The example below provisions a role for the EBS CSI driver, a common first IRSA consumer, then wires that role into the cluster add-on.

# main.tf (continued)
module "ebs_csi_irsa" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version = "~> 5.44"

  role_name             = "${var.cluster_name}-ebs-csi"
  attach_ebs_csi_policy = true

  oidc_providers = {
    main = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
    }
  }

  tags = local.tags
}

module.eks.oidc_provider_arn is the link back to the provider EKS created, and namespace_service_accounts restricts the trust to exactly the ebs-csi-controller-sa service account in kube-system. No other pod can assume this role. The same pattern works for any workload: set role_policy_arns to the policies it needs and list the service accounts allowed to use it.

To make your own application role, you would write a similar block, attach a custom policy through role_policy_arns, and then annotate the pod’s service account with eks.amazonaws.com/role-arn pointing at the role ARN. The module surfaces that ARN as an output you can reference in a kubernetes_service_account resource or a Helm value.

Step 6: Configure kubectl and Verify

Wire up a couple of outputs first so you do not have to dig through state for the values you need.

# outputs.tf
output "cluster_name" {
  description = "EKS cluster name"
  value       = module.eks.cluster_name
}

output "cluster_endpoint" {
  description = "EKS API server endpoint"
  value       = module.eks.cluster_endpoint
}

output "region" {
  description = "AWS region"
  value       = var.region
}

output "configure_kubectl" {
  description = "Command to update your kubeconfig"
  value       = "aws eks update-kubeconfig --name ${module.eks.cluster_name} --region ${var.region}"
}

Now initialize, review the plan, and apply. The first apply provisions roughly 60 resources and the control plane alone takes around 10 minutes, so expect the whole run to land near 15 minutes.

terraform init
terraform plan
terraform apply

Once the apply finishes, point kubectl at the new cluster. The update-kubeconfig command writes a context into ~/.kube/config that authenticates through the AWS CLI, so no static kubeconfig secrets are involved.

aws eks update-kubeconfig --name demo-eks --region us-west-2

Verify the nodes have joined and the system pods are healthy:

kubectl get nodes
kubectl get pods -n kube-system

You should see two nodes in Ready state and the CoreDNS, kube-proxy, aws-node, and EBS CSI pods running. If kubectl get nodes returns the nodes, the control plane, networking, node IAM roles, and the VPC CNI are all working together.

NAME                                       STATUS   ROLES    AGE   VERSION
ip-10-0-1-42.us-west-2.compute.internal    Ready    <none>   3m    v1.30.x
ip-10-0-2-87.us-west-2.compute.internal    Ready    <none>   3m    v1.30.x

Step 7: Add the EBS CSI Add-on

With the IRSA role from Step 5 in place, you can let EKS manage the EBS CSI driver as an add-on and hand it the role so PersistentVolumeClaims backed by EBS work out of the box. Add this entry to the cluster_addons map in your module "eks" block, then reapply.

# main.tf -- add to the cluster_addons map
    aws-ebs-csi-driver = {
      most_recent              = true
      service_account_role_arn = module.ebs_csi_irsa.iam_role_arn
    }
terraform apply

Confirm the driver is up and exposes a default-capable storage class, then optionally mark gp3 as the default so new claims land on the cheaper, faster volume type.

kubectl get pods -n kube-system -l app=ebs-csi-controller
kubectl get storageclass

This same add-on pattern extends to anything EKS supports as a managed add-on, and the broader IRSA pattern covers controllers you install through Helm, such as the AWS Load Balancer Controller or external-dns.

Cleanup

Every resource in this stack costs money while it runs, so tear it down when you are finished. Terraform destroys in dependency order, removing node groups before the control plane and the VPC last.

terraform destroy

If the destroy stalls on the VPC because a load balancer or ENI created by Kubernetes still exists, delete the offending Kubernetes service first (kubectl delete svc <name>), wait for AWS to release the load balancer, then rerun terraform destroy. Orphaned ENIs from the VPC CNI are the most common cause of a stuck VPC deletion.

Next Steps

You now have a repeatable EKS deployment defined entirely in Terraform. A few directions to take it further:

  • Move state to S3. For any shared or production environment, add a backend block with an S3 bucket and a DynamoDB table for state locking so teammates do not clobber each other’s runs.
# versions.tf -- add inside the terraform block
  backend "s3" {
    bucket         = "my-tf-state-bucket"
    key            = "eks/demo/terraform.tfstate"
    region         = "us-west-2"
    dynamodb_table = "tf-locks"
    encrypt        = true
  }
  • Add autoscaling. Install Karpenter or the Cluster Autoscaler so the node group reacts to pending pods instead of needing manual desired_size changes.
  • Harden the endpoint. Set cluster_endpoint_public_access = false and reach the API through a VPN or bastion once you are past the tutorial stage.
  • Install the AWS Load Balancer Controller using the IRSA pattern from Step 5 so Kubernetes Ingress resources provision real Application Load Balancers in your public subnets.

Each of these slots into the same files you already wrote, which is the payoff of describing the cluster as code: the next change is a small, reviewable diff rather than a fresh round of console clicks.

Get the next article in your inbox

Practical DevOps tips, tutorials, and guides. No spam, unsubscribe anytime.