Amazon EKS gives you a managed Kubernetes control plane; you still have to build the network, the node groups, IAM, and the add-ons around it. Doing that by clicking is slow and unrepeatable, so this tutorial does it with Terraform using the community terraform-aws-modules for VPC and EKS, which encode AWS best practices and are used by thousands of teams. At the end you will have a Kubernetes 1.33 cluster with two managed node groups across three availability zones, EKS Pod Identity for workload permissions, the AWS Load Balancer Controller installed with Helm, and a sample application reachable through an Application Load Balancer. Versions used: Terraform 1.10+, AWS provider 6.x, EKS module 21.x, VPC module 6.x.
Prerequisites: an AWS account with rights to create VPC, EKS, IAM and EC2 resources; Terraform, AWS CLI v2, kubectl and helm installed; familiarity with the Terraform series and the Kubernetes basics. Cost warning: an EKS control plane costs about 0.10 USD/hour plus the nodes and NAT gateways; the lab below is roughly 0.30–0.40 USD/hour. Destroy it when you finish.
Target architecture
- VPC 10.0.0.0/16 with three public and three private subnets, one NAT gateway (single for the lab; one per AZ for production), tags required by EKS and the load balancer controller.
- EKS cluster 1.33, private + public endpoint, control-plane logs to CloudWatch, secrets encrypted with KMS.
- Managed node groups:
general(On-Demand, m7g.large, Graviton, Bottlerocket) andspot(mixed instance types) in private subnets. - Add-ons managed by EKS: VPC CNI, CoreDNS, kube-proxy, EBS CSI driver, Pod Identity agent, metrics server.
- AWS Load Balancer Controller via Helm, authorised with Pod Identity.
- Access through EKS access entries (the modern replacement for the
aws-authConfigMap).
Step 1 – Project layout and providers
eks-lab/
├── versions.tf
├── variables.tf
├── vpc.tf
├── eks.tf
├── addons.tf
├── outputs.tf
└── backend.tf # S3 remote state, see our remote state guide# versions.tf
terraform {
required_version = ">= 1.10"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.35" }
helm = { source = "hashicorp/helm", version = "~> 3.0" }
}
}
provider "aws" {
region = var.region
default_tags {
tags = { Project = var.name, ManagedBy = "terraform" }
}
}
# Kubernetes and Helm providers authenticate with a short-lived token from the AWS CLI
provider "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, "--region", var.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, "--region", var.region]
}
}
}# variables.tf
variable "region" { type = string, default = "ca-central-1" }
variable "name" { type = string, default = "eks-lab" }
variable "cluster_version" { type = string, default = "1.33" }
variable "vpc_cidr" { type = string, default = "10.0.0.0/16" }
variable "admin_principal_arn" {
description = "IAM role or user ARN that gets cluster-admin (e.g. your SSO role)"
type = string
}Step 2 – The VPC
# vpc.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)
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 6.0"
name = var.name
cidr = var.vpc_cidr
azs = local.azs
private_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 4, k)] # /20 each
public_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 8, k + 48)] # /24 each
enable_nat_gateway = true
single_nat_gateway = true # false in production: one NAT per AZ
enable_dns_hostnames = true
# Tags the AWS Load Balancer Controller uses for subnet discovery
public_subnet_tags = { "kubernetes.io/role/elb" = 1 }
private_subnet_tags = { "kubernetes.io/role/internal-elb" = 1 }
}Step 3 – The EKS cluster and node groups
# eks.tf
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 21.0"
name = var.name
kubernetes_version = var.cluster_version
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
endpoint_public_access = true # restrict with endpoint_public_access_cidrs in production
endpoint_private_access = true
enabled_log_types = ["api", "audit", "authenticator"]
# Grant the identity running Terraform cluster-admin (access entries API)
enable_cluster_creator_admin_permissions = true
access_entries = {
admin = {
principal_arn = var.admin_principal_arn
policy_associations = {
admin = {
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
access_scope = { type = "cluster" }
}
}
}
}
# EKS-managed add-ons; most_recent picks the version matching the cluster
addons = {
coredns = { most_recent = true }
kube-proxy = { most_recent = true }
vpc-cni = { most_recent = true, before_compute = true }
eks-pod-identity-agent = { most_recent = true, before_compute = true }
aws-ebs-csi-driver = { most_recent = true, pod_identity_association = [{ role_arn = module.ebs_csi_pod_identity.iam_role_arn, service_account = "ebs-csi-controller-sa" }] }
metrics-server = { most_recent = true }
}
eks_managed_node_groups = {
general = {
ami_type = "BOTTLEROCKET_ARM_64"
instance_types = ["m7g.large"]
capacity_type = "ON_DEMAND"
min_size = 2
max_size = 4
desired_size = 2
labels = { workload = "general" }
}
spot = {
ami_type = "BOTTLEROCKET_ARM_64"
instance_types = ["m7g.large", "m6g.large", "c7g.large", "c6g.large"]
capacity_type = "SPOT"
min_size = 0
max_size = 6
desired_size = 1
labels = { workload = "spot" }
taints = {
spot = { key = "spot", value = "true", effect = "NO_SCHEDULE" }
}
}
}
}
# IAM role for the EBS CSI driver, bound with Pod Identity (no OIDC trust policies to maintain)
module "ebs_csi_pod_identity" {
source = "terraform-aws-modules/eks-pod-identity/aws"
version = "~> 2.0"
name = "${var.name}-ebs-csi"
attach_aws_ebs_csi_policy = true
# association is declared on the add-on above
}Why Pod Identity instead of IRSA? IAM Roles for Service Accounts required an OIDC provider per cluster and a trust policy per role. EKS Pod Identity (2023+) uses one agent add-on and an association object between a role and a service account; the role’s trust policy is the same for every cluster (pods.eks.amazonaws.com). IRSA still works and is needed for some third-party charts, but new setups should default to Pod Identity.
Step 4 – AWS Load Balancer Controller
# addons.tf
module "lbc_pod_identity" {
source = "terraform-aws-modules/eks-pod-identity/aws"
version = "~> 2.0"
name = "${var.name}-aws-lbc"
attach_aws_lb_controller_policy = true
associations = {
lbc = {
cluster_name = module.eks.cluster_name
namespace = "kube-system"
service_account = "aws-load-balancer-controller"
}
}
}
resource "helm_release" "aws_load_balancer_controller" {
name = "aws-load-balancer-controller"
repository = "https://aws.github.io/eks-charts"
chart = "aws-load-balancer-controller"
namespace = "kube-system"
version = "1.13.3" # check the chart's releases for the latest
set = [
{ name = "clusterName", value = module.eks.cluster_name },
{ name = "serviceAccount.create", value = "true" },
{ name = "serviceAccount.name", value = "aws-load-balancer-controller" },
{ name = "region", value = var.region },
{ name = "vpcId", value = module.vpc.vpc_id },
]
depends_on = [module.eks, module.lbc_pod_identity]
}# outputs.tf
output "cluster_name" { value = module.eks.cluster_name }
output "cluster_endpoint" { value = module.eks.cluster_endpoint }
output "configure_kubectl" {
value = "aws eks update-kubeconfig --region ${var.region} --name ${module.eks.cluster_name}"
}Step 5 – Apply
export TF_VAR_admin_principal_arn="$(aws sts get-caller-identity --query Arn --output text | sed -E 's#:assumed-role/([^/]+)/.*#:role/1#')"
terraform init
terraform plan -out=tfplan # ~60 resources
terraform apply tfplan # 12–15 minutes: the control plane alone takes ~10
$(terraform output -raw configure_kubectl)
kubectl get nodes -o wide
# 3 nodes Ready, Bottlerocket OS, containerd
kubectl get pods -n kube-system
# aws-load-balancer-controller, coredns, ebs-csi-*, eks-pod-identity-agent, kube-proxy, aws-node, metrics-serverIf the Kubernetes/Helm providers fail on the first apply with “connection refused”, it is because the cluster did not exist at plan time. Run terraform apply -target=module.eks first, then a full terraform apply; the module docs describe this two-phase apply.
Step 6 – Deploy an application behind an ALB
# game.yaml
apiVersion: v1
kind: Namespace
metadata: { name: game-2048 }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: app-2048, namespace: game-2048 }
spec:
replicas: 3
selector: { matchLabels: { app: app-2048 } }
template:
metadata: { labels: { app: app-2048 } }
spec:
containers:
- name: app-2048
image: public.ecr.aws/l6m2t8p7/docker-2048:latest
ports: [{ containerPort: 80 }]
resources:
requests: { cpu: 50m, memory: 64Mi }
---
apiVersion: v1
kind: Service
metadata: { name: service-2048, namespace: game-2048 }
spec:
type: NodePort
selector: { app: app-2048 }
ports: [{ port: 80, targetPort: 80 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-2048
namespace: game-2048
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
spec:
ingressClassName: alb
rules:
- http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: service-2048, port: { number: 80 } } }kubectl apply -f game.yaml
kubectl get ingress -n game-2048 -w # ADDRESS appears after ~2 minutes
curl -sI "http://$(kubectl get ingress ingress-2048 -n game-2048 -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')" | head -1
# HTTP/1.1 200 OK
# Persistent storage through the EBS CSI driver
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: data, namespace: game-2048 }
spec:
accessModes: [ReadWriteOnce]
storageClassName: gp2
resources: { requests: { storage: 5Gi } }
EOF
kubectl get pvc -n game-2048 # Pending until a pod uses it (WaitForFirstConsumer)Step 7 – Use the Spot node group
# Pod spec excerpt: opt in to spot nodes
spec:
nodeSelector:
workload: spot
tolerations:
- key: spot
operator: Equal
value: "true"
effect: NoScheduleFor automatic node provisioning based on pending pods (instead of fixed node groups), add Karpenter in a follow-up; the EKS module has a companion karpenter sub-module. For EKS Auto Mode, set compute_config = { enabled = true, node_pools = ["general-purpose"] } and drop the node groups; AWS then manages nodes, networking and load balancing add-ons for a per-node fee.
Production hardening checklist
endpoint_public_access_cidrslimited to your office/VPN, or public access disabled with a bastion/VPN.- One NAT gateway per AZ (
single_nat_gateway = false) and node groups spread across the three private subnets. - Cluster secrets encryption with a customer-managed KMS key (the module enables it by default; pass your own key ARN).
- Network policies with the VPC CNI (
enableNetworkPolicy) or Cilium; Pod Security admission set to restricted for application namespaces. - GitOps (Argo CD or Flux) for application manifests; Terraform only for the platform.
- Upgrades: bump
cluster_versionone minor at a time, let managed add-ons follow withmost_recent, and roll node groups (the module updates the launch template; EKS replaces nodes gracefully). - Observability: CloudWatch Container Insights or Prometheus/Grafana (see Prometheus) plus the control-plane logs already enabled.
Troubleshooting
- Nodes never join (NotReady/unknown) – subnets lack a route to the internet or to the EKS endpoint; verify NAT gateway and the
vpc-cniadd-on status (aws eks describe-addon). - “You must be logged in to the server (Unauthorized)” – your identity has no access entry; add it to
access_entriesor setenable_cluster_creator_admin_permissions. - Ingress has no ADDRESS – controller logs (
kubectl logs -n kube-system deploy/aws-load-balancer-controller) usually show missing subnet tags or a Pod Identity association not yet active. - PVC stuck Pending – EBS CSI driver pods failing on IAM; check the Pod Identity association and the driver logs.
- terraform destroy hangs on the VPC – load balancers created by Ingress objects are unknown to Terraform; delete the Ingresses first (below).
Clean up
kubectl delete -f game.yaml # removes the ALB via the controller
sleep 60
terraform destroy # ~10 minutesKey takeaways
- The
terraform-aws-modulesVPC and EKS modules turn a 1 000-line build into ~150 lines of intent. - Use access entries for cluster access, Pod Identity for workload IAM, managed add-ons for the core components.
- Mix On-Demand and Spot node groups with labels and taints; consider Karpenter or Auto Mode for elasticity.
- Delete Kubernetes-created load balancers before
terraform destroy, and always destroy lab clusters.
Related: EKS introduction, EKS cluster lab (console/eksctl), Cluster Autoscaler, EBS persistent volumes, Terraform VPC. Official docs: EKS Best Practices Guide, EKS module on the Terraform Registry.


