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
- InternetTraffic enters through the VPC’s internet gateway
- Public subnetsThree, one per availability zone, holding the NAT gateway and internet-facing load balancers
- Private subnetsThree, one per availability zone, holding the EKS managed node group
- 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 configureor AWS SSO - Terraform 1.11 or newer
kubectlwithin 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:
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.
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.
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.
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.
| v20 input | v21 input |
|---|---|
cluster_name | name |
cluster_version | kubernetes_version |
cluster_endpoint_public_access | endpoint_public_access |
cluster_endpoint_private_access | endpoint_private_access |
cluster_endpoint_public_access_cidrs | endpoint_public_access_cidrs |
cluster_addons | addons |
cluster_enabled_log_types | enabled_log_types |
cluster_encryption_config | encryption_config |
cluster_ip_family | ip_family |
cluster_service_ipv4_cidr | service_ipv4_cidr |
cluster_security_group_name | security_group_name |
cluster_identity_providers | identity_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:
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:
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:
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.
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:
# 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.
# 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:
| 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.
# 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.