Document

SUBSCRIBE TO GET FULL ACCESS TO THE E-BOOKS FOR FREE 🎁SUBSCRIBE NOW

Professional Dropdown with Icon

SUBSCRIBE NOW TO GET FREE ACCESS TO EBOOKS

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) and spot (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-auth ConfigMap).

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-server

If 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: NoSchedule

For 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_cidrs limited 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_version one minor at a time, let managed add-ons follow with most_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-cni add-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_entries or set enable_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 minutes

Key takeaways

  • The terraform-aws-modules VPC 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.

← Retour parcours Terraform

Share your love

Leave a Reply

Your email address will not be published. Required fields are marked *