Deploy a Kubernetes cluster on Amazon EKS with Terraform

A complete 2026 guide: VPC, cluster, managed node groups, access entries, add-ons, production hardening and a clean teardown.

Introduction

Managing Kubernetes clusters by hand in the AWS console is error-prone and hard to repeat. Infrastructure as code fixes that: Terraform with Amazon EKS (Elastic Kubernetes Service) gives you a declarative, version-controlled cluster that you can review like any other code.

In this guide we build a production-ready EKS setup from scratch with the current community modules (EKS module v21). We use EKS access entries instead of the old aws-auth ConfigMap, and EKS Pod Identity to give pods AWS permissions.

What we’ll build

From the internet to the control plane, inside a 10.0.0.0/16 VPC

  1. InternetTraffic enters through the VPC’s internet gateway
  2. Public subnetsThree, one per availability zone, holding the NAT gateway and internet-facing load balancers
  3. Private subnetsThree, one per availability zone, holding the EKS managed node group
  4. Control planeThe Kubernetes API server and etcd, run and patched by AWS

Prerequisites

  • An AWS account and an IAM identity that can create VPC, EKS, IAM and KMS resources
  • AWS CLI v2, configured with aws configure or AWS SSO
  • Terraform 1.11 or newer
  • kubectl within one minor version of your cluster
  • An S3 bucket for Terraform state, with versioning turned on
  • A working knowledge of Kubernetes basics

Step 1: Project structure and remote backend

A maintainable Terraform project separates concerns. Create a new directory and lay out your files like this:

Directory structure
eks-terraform/
├── versions.tf
├── providers.tf
├── variables.tf
├── vpc.tf
├── eks.tf
├── outputs.tf
└── terraform.tfvars

Next, pin Terraform and the providers, and keep state in S3. Terraform 1.11 and later can lock state with a lock file in the same bucket (use_lockfile), so you no longer need a DynamoDB table.

versions.tf
terraform {
  required_version = ">= 1.11"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.59"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 3.0"
    }
  }

  backend "s3" {
    bucket       = "techwithhassan-terraform-state" # replace with your bucket
    key          = "eks-cluster/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true # S3-native state locking
    encrypt      = true
  }
}

Step 2: VPC network architecture

EKS needs a solid network layer. We use the community VPC module to create public subnets (for the NAT gateway and internet-facing load balancers) and private subnets, where the worker nodes and pods run.

Note the subnet tags. The AWS Load Balancer Controller uses them to decide where to place load balancers.

vpc.tf
data "aws_availability_zones" "available" {
  state = "available"
}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.7"

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

  azs             = slice(data.aws_availability_zones.available.names, 0, 3)
  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 # one NAT keeps costs down; use one per AZ in production
  enable_dns_hostnames = true

  # Tags the AWS Load Balancer Controller uses to find subnets
  public_subnet_tags = {
    "kubernetes.io/role/elb" = 1
  }

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

Step 3: EKS cluster and managed node group

Now the main part. Version 21 of terraform-aws-modules/eks renamed many inputs: cluster_name became name, cluster_version became kubernetes_version, cluster_endpoint_public_access became endpoint_public_access, and cluster_addons became addons. Most examples online still use the v20 names, which is the usual cause of “Unsupported argument” errors.

eks.tf
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 21.26"

  name               = var.cluster_name
  kubernetes_version = var.kubernetes_version

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

  # Public API endpoint so you can reach the cluster from your laptop.
  # Lock this down for production (see Step 7).
  endpoint_public_access = true

  # EKS managed add-ons. The VPC CNI and the Pod Identity agent are
  # created before the node group so nodes start with them in place.
  addons = {
    coredns    = {}
    kube-proxy = {}
    vpc-cni = {
      before_compute = true
    }
    eks-pod-identity-agent = {
      before_compute = true
    }
  }

  # Access entries instead of the legacy aws-auth ConfigMap
  authentication_mode                      = "API"
  enable_cluster_creator_admin_permissions = true

  eks_managed_node_groups = {
    default = {
      instance_types = ["t3.medium"]
      min_size       = 2
      max_size       = 5
      desired_size   = 2
    }
  }
}

authentication_mode = "API" turns on access entries and turns off the aws-auth ConfigMap. enable_cluster_creator_admin_permissions gives the identity that runs Terraform cluster-admin access through an access entry, so kubectl works straight after the apply.

Two things you get without asking: the module sends control-plane logs (API, audit and authenticator) to CloudWatch, and it encrypts Kubernetes secrets with a KMS key it creates. Both are on by default in v21. Node groups default to Amazon Linux 2023.

EKS module v20 to v21: renamed inputs

If you’re upgrading an existing configuration, rename these inputs. The list comes from the module’s official v21 upgrade guide.

Input names in terraform-aws-modules/eks v20 and v21
v20 input v21 input
cluster_namename
cluster_versionkubernetes_version
cluster_endpoint_public_accessendpoint_public_access
cluster_endpoint_private_accessendpoint_private_access
cluster_endpoint_public_access_cidrsendpoint_public_access_cidrs
cluster_addonsaddons
cluster_enabled_log_typesenabled_log_types
cluster_encryption_configencryption_config
cluster_ip_familyip_family
cluster_service_ipv4_cidrservice_ipv4_cidr
cluster_security_group_namesecurity_group_name
cluster_identity_providersidentity_providers

v21 also needs Terraform 1.5.7 or newer and AWS provider 6, and it removes the aws-auth sub-module.

Fixing “Unsupported argument” after upgrading

If terraform plan fails with an error like this one, your configuration still uses a v20 input name:

terraform plan output
Error: Unsupported argument

  on eks.tf line 5, in module "eks":
   5:   cluster_name    = var.cluster_name

An argument named "cluster_name" is not expected here.

Rename the argument using the table above (here, cluster_name becomes name), then run terraform init -upgrade and terraform plan again. Read the plan carefully before applying an upgrade to an existing cluster: a change that replaces the cluster or its node groups shows up as “must be replaced”.

Step 4: Variables and outputs

Define the inputs so the configuration is reusable:

variables.tf
variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "cluster_name" {
  description = "Name of the EKS cluster"
  type        = string
}

variable "kubernetes_version" {
  description = "Kubernetes minor version for the control plane and nodes"
  type        = string
  default     = "1.36"
}

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

Then the outputs, so you can find the cluster details after the deploy:

outputs.tf
output "cluster_endpoint" {
  description = "Endpoint for the EKS control plane"
  value       = module.eks.cluster_endpoint
}

output "cluster_name" {
  description = "Kubernetes cluster name"
  value       = module.eks.cluster_name
}

output "cluster_security_group_id" {
  description = "Security group ID attached to the control plane"
  value       = module.eks.cluster_security_group_id
}

output "cluster_certificate_authority_data" {
  description = "Base64-encoded certificate data for the cluster"
  value       = module.eks.cluster_certificate_authority_data
}

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

Configure the providers. Helm provider 3 sets the cluster connection with attributes (kubernetes = { … }) rather than blocks, so older kubernetes { … } examples fail validation.

providers.tf
provider "aws" {
  region = var.aws_region
}

provider "helm" {
  kubernetes = {
    host                   = module.eks.cluster_endpoint
    cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)

    exec = {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args        = ["eks", "get-token", "--cluster-name", module.eks.cluster_name]
    }
  }
}

Finally, set your own values:

terraform.tfvars
# Customize these values for your environment
aws_region         = "us-east-1"
cluster_name       = "my-eks-cluster"
kubernetes_version = "1.36"
vpc_cidr           = "10.0.0.0/16"

Pick a Kubernetes version in standard support. In October 2026 that is 1.34 to 1.37. Run aws eks describe-cluster-versions to see the current list for your region.

Step 5: Deploy the cluster

Initialize the project, save a plan, review it, and apply exactly that plan. The first apply takes about 15 to 20 minutes while EKS creates the control plane and the node group. From this point the cluster costs money by the hour, so tear it down (Step 8) when you’re done.

Terminal
# Download providers and modules, and connect the S3 backend
$ terraform init

# Save the plan, read it, then apply that exact plan
$ terraform plan -out=tfplan
$ terraform apply tfplan

# Point kubectl at the new cluster
$ aws eks update-kubeconfig --region us-east-1 --name my-eks-cluster

# Check that the nodes joined (your patch version will differ)
$ kubectl get nodes
NAME                         STATUS   ROLES    AGE   VERSION
ip-10-0-1-23.ec2.internal    Ready    <none>   5m    v1.36.x
ip-10-0-2-45.ec2.internal    Ready    <none>   5m    v1.36.x

Step 6: Day-2 operations

A fresh cluster needs a few controllers before it’s useful. The most important is the AWS Load Balancer Controller, which creates Application Load Balancers for Ingress objects and Network Load Balancers for Services of type LoadBalancer.

Install it with Helm into the kube-system namespace, and give it its IAM permissions through EKS Pod Identity (the agent is already installed by the module above) or through IAM roles for service accounts (IRSA).

Other add-ons most clusters need: metrics-server for kubectl top and pod autoscaling, the EBS CSI driver for persistent volumes (available as an EKS add-on), and Karpenter or EKS Auto Mode for node autoscaling.

Step 7: Production hardening

The configuration above is a good starting point. For production, review these settings:

Settings in this guide compared with a production setup
Setting In this guide Production
API endpoint Public (endpoint_public_access = true) Private only, reached over a VPN, or public with endpoint_public_access_cidrs limited to known ranges
Control-plane logs API, audit and authenticator logs (module default) Keep them, set cloudwatch_log_group_retention_in_days, and forward to your log platform
Secrets encryption KMS key created by the module (default) Keep it, and limit who can manage the key with kms_key_administrators
NAT gateways One, shared by all zones One per availability zone (single_nat_gateway = false)
Node scaling Managed node group with fixed limits Karpenter or EKS Auto Mode for faster, cheaper scaling

Step 8: Clean teardown

When you finish testing, destroy everything to stop the charges. Delete Kubernetes objects that created AWS resources first. Load balancers and volumes made by controllers live outside Terraform’s state, and they keep network interfaces in your subnets, so terraform destroy hangs on the VPC until they’re gone.

Terminal
# 1. Delete Kubernetes objects that created AWS resources (the second command needs jq)
$ kubectl delete ingress --all --all-namespaces
$ kubectl get svc -A -o json \
    | jq -r '.items[] | select(.spec.type=="LoadBalancer") | "\(.metadata.namespace) \(.metadata.name)"' \
    | while read -r ns name; do kubectl delete svc -n "$ns" "$name"; done
$ kubectl delete pvc --all --all-namespaces

# 2. Destroy the infrastructure (review the plan before you confirm)
$ terraform destroy

Frequently asked questions

How much does an EKS cluster cost?

The control plane costs $0.10 an hour (about $73 a month) while its Kubernetes version is in standard support, and $0.60 an hour in extended support. On top of that you pay for the EC2 nodes, EBS volumes, load balancers, NAT gateways and data transfer. A small cluster like this one usually comes to around $150 to $200 a month.

Should I use eksctl or Terraform for EKS?

eksctl is great for quick experiments. Terraform suits production, because it manages the cluster alongside the VPC, databases and IAM roles in one reviewed codebase, with plans you can check before anything changes.

How do I move from aws-auth to access entries?

Since module v20 you can set authentication_mode = "API" and grant access through the module’s access_entries input or the aws_eks_access_entry resource. Module v21 removed the aws-auth sub-module altogether. On an existing cluster, switch to API_AND_CONFIG_MAP first, create the access entries, then move to API.

Why does terraform destroy hang on EKS?

Kubernetes controllers create AWS resources, such as load balancers and EBS volumes, that Terraform doesn’t know about. Delete Services of type LoadBalancer, Ingresses and PersistentVolumeClaims before you run terraform destroy.

EKS Pod Identity or IRSA?

EKS Pod Identity is the newer, simpler way to give pods AWS permissions. You install the Pod Identity agent add-on and associate IAM roles with service accounts, with no OIDC trust policies to manage per cluster. IRSA still works, and the module still creates the OIDC provider for it by default.

Conclusion

You now have a production-grade Amazon EKS cluster defined in Terraform. The community modules replace hundreds of lines of HCL while leaving you in control of the VPC layout, authentication and node groups.

Everything you need is on this page: copy each file into one folder, set your values in terraform.tfvars, and run the commands in Step 5. For more DevOps and cloud walkthroughs, subscribe to Tech with Hassan on YouTube.