Skip to content
Infrastructure as Code iac cloud 4 min read

Recipe: EKS Cluster

Provisioning a production-grade Kubernetes control plane by hand involves dozens of interdependent resources — the control plane, an OIDC provider, IAM roles for nodes and service accounts, security groups, and managed node groups. The community terraform-aws-modules/eks/aws and terraform-aws-modules/vpc/aws modules collapse all of that into a few well-tested blocks, which is exactly where registry modules shine. This recipe builds a complete Amazon EKS cluster on a fresh VPC and emits a ready-to-use kubeconfig.

Cost warning: An EKS control plane bills roughly $0.10/hour (~$73/month) per cluster even when idle, plus EC2 charges for the node group and ~$0.005/hour per managed node. Run terraform destroy when you are done experimenting.

Provider and version setup

Pin Terraform, the AWS provider, and the helper providers the EKS module needs to talk to the cluster API. Everything here is OpenTofu-compatible — swap terraform for tofu on the CLI and the same configuration applies unchanged.

terraform {
  required_version = ">= 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

locals {
  cluster_name = "devcraftly-eks"
  k8s_version  = "1.30"
}

The network: a VPC module

EKS requires subnets spread across multiple availability zones, with public subnets for load balancers and private subnets for worker nodes. The VPC module wires up NAT gateways, route tables, and the subnet tags EKS expects for auto-discovery.

data "aws_availability_zones" "available" {
  state = "available"
}

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

  name = "${local.cluster_name}-vpc"
  cidr = "10.0.0.0/16"

  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
  enable_dns_hostnames = true

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

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

Tip: single_nat_gateway = true keeps lab costs down by sharing one NAT gateway. For production, set it to false so each AZ has its own NAT and you avoid a cross-AZ single point of failure.

The cluster and node group

The EKS module creates the control plane, the OIDC provider for IRSA (IAM Roles for Service Accounts), all required IAM roles and security groups, and one or more managed node groups. You describe node groups declaratively — instance types, scaling bounds, and capacity type — and the module handles the launch template and IAM plumbing.

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.24"

  cluster_name    = local.cluster_name
  cluster_version = local.k8s_version

  # Expose the API endpoint publicly for kubectl access from your machine.
  cluster_endpoint_public_access = true

  # Grant the identity running Terraform cluster-admin via an access entry.
  enable_cluster_creator_admin_permissions = true

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

  eks_managed_node_group_defaults = {
    ami_type = "AL2023_x86_64_STANDARD"
  }

  eks_managed_node_groups = {
    general = {
      instance_types = ["t3.medium"]
      capacity_type  = "ON_DEMAND"

      min_size     = 2
      max_size     = 4
      desired_size = 2

      labels = {
        role = "general"
      }
    }
  }

  tags = {
    Environment = "lab"
    ManagedBy   = "terraform"
  }
}

Node group options reference

FieldPurposeTypical value
instance_typesEC2 sizes the autoscaler may launch["t3.medium"]
capacity_typeBilling model for nodesON_DEMAND or SPOT
min_size / max_sizeBounds for the underlying ASG2 / 4
desired_sizeInitial node count2
ami_typeManaged AMI familyAL2023_x86_64_STANDARD
labelsKubernetes node labels{ role = "general" }

Wiring up kubeconfig

Rather than shelling out, generate a local kubeconfig from the module outputs and the cluster auth token. The aws eks update-kubeconfig command is the canonical, supported path, so expose the values it needs as outputs.

output "cluster_name" {
  value = module.eks.cluster_name
}

output "cluster_endpoint" {
  value = module.eks.cluster_endpoint
}

output "configure_kubectl" {
  description = "Run this to point kubectl at the new cluster."
  value       = "aws eks update-kubeconfig --region us-east-1 --name ${module.eks.cluster_name}"
}

Apply and verify

Initialize to download the modules and providers, then apply. Cluster creation takes 10-15 minutes because the control plane and node group provision sequentially.

terraform init
terraform apply

Output:

Plan: 64 to add, 0 to change, 0 to destroy.

module.eks.aws_eks_cluster.this[0]: Still creating... [9m20s elapsed]
module.eks.aws_eks_cluster.this[0]: Creation complete after 9m41s
module.eks.module.eks_managed_node_group["general"]...: Creation complete after 2m13s

Apply complete! Resources: 64 added, 0 changed, 0 destroyed.

Outputs:

cluster_endpoint = "https://A1B2C3.gr7.us-east-1.eks.amazonaws.com"
cluster_name = "devcraftly-eks"
configure_kubectl = "aws eks update-kubeconfig --region us-east-1 --name devcraftly-eks"

Point kubectl at the cluster and confirm the nodes registered:

aws eks update-kubeconfig --region us-east-1 --name devcraftly-eks
kubectl get nodes

Output:

Updated context arn:aws:eks:us-east-1:111122223333:cluster/devcraftly-eks in ~/.kube/config

NAME                          STATUS   ROLES    AGE   VERSION
ip-10-0-1-45.ec2.internal     Ready    <none>   2m    v1.30.4-eks-a737599
ip-10-0-2-88.ec2.internal     Ready    <none>   2m    v1.30.4-eks-a737599

When finished, tear everything down so the control plane stops billing:

terraform destroy

Best Practices

  • Keep worker nodes in private subnets and rely on public subnets only for load balancers — never expose nodes directly to the internet.
  • Use IRSA (the OIDC provider the module creates) to grant pods scoped IAM permissions instead of attaching broad policies to the node role.
  • Pin module versions with ~> and bump deliberately; the EKS module’s major versions track Kubernetes API and access-entry changes that can be breaking.
  • Run terraform destroy on lab clusters to avoid the ~$73/month control-plane charge — it accrues even with zero nodes.
  • Prefer managed node groups over self-managed instances so AWS handles AMI updates and graceful draining during rolling upgrades.
  • For production, set single_nat_gateway = false and use SPOT capacity for fault-tolerant, interruption-safe workloads to cut node costs.
  • Manage cluster access with EKS access entries (enable_cluster_creator_admin_permissions) rather than the legacy aws-auth ConfigMap.
Last updated June 14, 2026
Was this helpful?