AWS EKS VPC + Terraform
Standing up a production EKS Auto Mode cluster with Terraform - the VPC and subnets, how the cluster and IAM wire into them, and the gotchas that bite. Built from a real 3-AZ lab; every snippet is quoted from it.
Updated Jun 24, 2026
File layout
Provider
The AWS provider pins to the v6 major and sets the region once. default_tags stamps every resource the stack creates, so Terraform-managed infra is easy to find in the console and unambiguous on the bill.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Terraform = "true"
Environment = "prod"
}
}
}Variables
Only the names and the Pod Identity binding are parameterized. The network shape - CIDR, AZs, subnet ranges - is hardcoded in vpc.tf, which is fine for one cluster but the first thing to lift into variables before reusing the module across environments.
variable "eks_cluster_name" {
type = string
default = "my-eks-prod"
}
variable "vpc_name" {
type = string
default = "my-vpc-prod"
}
variable "pod_identity_namespace" {
type = string
default = "project-n"
}
variable "pod_identity_service_account" {
type = string
default = "sample-sa"
}The VPC
A single 10.0.0.0/16 spanning all three us-east-1 AZs. Nodes live in roomy private /20s; the internet-facing ALB and the NAT gateways live in tight public /24s. Subnet lists are index-aligned to the azs list - private_subnets[0] and public_subnets[0] both land in us-east-1a.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "6.6.1"
name = var.vpc_name
cidr = "10.0.0.0/16"
# Span all 3 AZs for high availability.
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.0.0/20", "10.0.16.0/20", "10.0.32.0/20"] # nodes
public_subnets = ["10.0.48.0/24", "10.0.49.0/24", "10.0.50.0/24"] # ALB + NAT
# HA egress: one NAT per AZ so one AZ failure can't cut off the others.
enable_nat_gateway = true
single_nat_gateway = false
one_nat_gateway_per_az = true
enable_dns_hostnames = true # required for the private endpoint + CNI
enable_dns_support = true
# Subnet discovery: the LB controller + Auto Mode find subnets by these tags.
public_subnet_tags = {
"kubernetes.io/role/elb" = 1 # internet-facing LBs
"kubernetes.io/cluster/${var.eks_cluster_name}" = "owned"
}
private_subnet_tags = {
"kubernetes.io/role/internal-elb" = 1 # internal LBs + nodes
"kubernetes.io/cluster/${var.eks_cluster_name}" = "owned"
}
}Those subnet tags are load-bearing: the AWS Load Balancer controller and the Auto Mode NodeClass discover subnets by tag, so the classic "my ALB won't provision" bug is almost always a missing one. The app's IngressClassParams sets scheme: internet-facing, landing the ALB in the role/elb public subnets; scheme: internal would route it to the internal-elb private subnets instead.
Subnets, routing & AZ-from-IP
Each subnet maps 1:1 to an AZ, and because the VPC CNI hands every pod a real secondary IP from its node's subnet (not an overlay), a pod's IP reveals its AZ - and target-type: ip ALBs and security-groups-for-pods become possible. Routing is module-managed: public subnets route 0.0.0.0/0 to the single IGW; each private subnet routes 0.0.0.0/0to its own AZ's NAT gateway.
Sizing follows from "one IP per pod". AWS reserves 5 addresses per subnet, so a /20 holds ~4,091 usable and a /24 holds 251. Size node subnets for max nodes × IPs-per-node; the lab puts the headroom where pods live (private /20) and keeps the public tier tight (/24) since it only holds the ALB and NAT. Traffic in: internet → IGW → public-subnet ALB → pod IPs; node egress out: private node → AZ NAT → IGW.
one_nat_gateway_per_az means three NAT gateways (~3× the cost of a single shared NAT, roughly $0.045/hr each plus data processing). A single NAT is cheaper but makes one AZ the failure domain for all egress. This lab buys the HA.Pending with no CPU/memory shortage. Fixes: size node subnets generously, spread pods across AZs (which spreads IP draw across the three subnets), or enable CNI prefix delegation (not used here, but the standard mitigation for high pod density).The cluster
The EKS module attaches to the VPC by ID and runs both the nodes and the control-plane ENIs in the private subnets. Auto Mode is the entire compute_config block.
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "21.23.0"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets # nodes (no public IP)
control_plane_subnet_ids = module.vpc.private_subnets # control-plane ENIs
name = var.eks_cluster_name
authentication_mode = "API"
kubernetes_version = "1.36"
enabled_log_types = ["api", "audit", "authenticator",
"controllerManager", "scheduler"]
endpoint_public_access = true
endpoint_private_access = true
enable_cluster_creator_admin_permissions = true
create_auto_mode_iam_resources = true
compute_config = {
enabled = true
node_pools = ["general-purpose", "system"]
}
}endpoint_public_access = true with no endpoint_public_access_cidrs leaves the API server reachable from 0.0.0.0/0. For anything real, set endpoint_public_access_cidrs to your office/VPN ranges, or turn the public endpoint off and reach the API over the private endpoint.IAM - Pod Identity
There is no hand-written cluster or node role - create_auto_mode_iam_resources makes those. The only IAM here is the application'sAWS access, via EKS Pod Identity. Auto Mode already runs the Pod Identity Agent, so there's nothing to install: just a role the EKS service can assume, and an association binding it to a namespace + service account.
# Trust: let the Pod Identity principal assume this role.
# sts:TagSession is required so EKS can attach session tags.
data "aws_iam_policy_document" "pod_identity_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole", "sts:TagSession"]
principals {
type = "Service"
identifiers = ["pods.eks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "app" {
name = "${var.eks_cluster_name}-app-pod-identity"
assume_role_policy = data.aws_iam_policy_document.pod_identity_trust.json
}
# Bind the role to a namespace + service account in the cluster.
resource "aws_eks_pod_identity_association" "app" {
cluster_name = module.eks.cluster_name
namespace = var.pod_identity_namespace # project-n
service_account = var.pod_identity_service_account # sample-sa
role_arn = aws_iam_role.app.arn
}Apply & verify
terraform init && terraform apply
# Write a dedicated kubeconfig instead of merging into ~/.kube/config.
aws eks update-kubeconfig --name my-eks-prod --region us-east-1 \
--kubeconfig ./kubeconfig.yaml
export KUBECONFIG=./kubeconfig.yaml
# Nodes are named by instance id; -L surfaces the AZ label.
kubectl get nodes -L topology.kubernetes.io/zone
kubectl get nodepools # general-purpose + system, READY, NODES from 0
# Confirm the subnets are tagged for this cluster:
aws ec2 describe-subnets \
--filters "Name=tag:kubernetes.io/cluster/my-eks-prod,Values=owned" \
--query 'Subnets[].{az:AvailabilityZone,cidr:CidrBlock}' --output table