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 destroywhen 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 = truekeeps lab costs down by sharing one NAT gateway. For production, set it tofalseso 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
| Field | Purpose | Typical value |
|---|---|---|
instance_types | EC2 sizes the autoscaler may launch | ["t3.medium"] |
capacity_type | Billing model for nodes | ON_DEMAND or SPOT |
min_size / max_size | Bounds for the underlying ASG | 2 / 4 |
desired_size | Initial node count | 2 |
ami_type | Managed AMI family | AL2023_x86_64_STANDARD |
labels | Kubernetes 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 destroyon 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 = falseand useSPOTcapacity 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 legacyaws-authConfigMap.