References

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

FileHolds
provider.tfAWS provider, region, default tags.
variables.tfCluster + VPC names, Pod Identity namespace/service account. (CIDRs and AZs are hardcoded in vpc.tf.)
vpc.tfVPC, public/private subnets, NAT, subnet role tags.
eks.tfCluster, Auto Mode compute, endpoint access, control-plane logging.
iam.tfPod Identity role, trust policy, association, output.
.terraform.lock.hclProvider version lock - generated by terraform init, committed to the repo.

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.

provider.tfhcl
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      Terraform   = "true"
      Environment = "prod"
    }
  }
}
SettingValueWhy
aws version~> 6.0Allows any 6.x, blocks a breaking 7.0 jump on the next init.
regionus-east-1Single-region stack; the AZ list in vpc.tf must belong to this region.
default_tagsTerraform, EnvironmentStamped on every resource for ownership and cost attribution.

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.

variables.tfhcl
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"
}
VariableDefaultPurpose
eks_cluster_namemy-eks-prodCluster name; also baked into the subnet cluster-owned tag.
vpc_namemy-vpc-prodName tag on the VPC.
pod_identity_namespaceproject-nNamespace half of the Pod Identity binding.
pod_identity_service_accountsample-saService account bound to the app IAM role.

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.

vpc.tfhcl
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"
  }
}
SettingValueWhy
cidr10.0.0.0/1665,536 addresses for the whole VPC.
azs1a · 1b · 1cThree AZs; the subnet lists index-align to this order.
private_subnetsthree /20Node + pod IPs, no public IP. ~4,091 usable each.
public_subnetsthree /24Internet-facing ALB and the NAT gateways. 251 usable each.
one_nat_gateway_per_aztrueOne NAT per AZ - no shared single-AZ egress SPOF (cost is ~3× a single NAT).
enable_dns_hostnames / _supporttrueRequired for the private cluster endpoint and the VPC CNI.
kubernetes.io/role/elbpublic = 1Where internet-facing ALBs may be placed.
kubernetes.io/role/internal-elbprivate = 1Where internal LBs go; Auto Mode also discovers node subnets here.
kubernetes.io/cluster/<name>both = ownedTies the subnet to this cluster (owned = it created them; shared = multi-cluster).

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.

SubnetRangeAZUsableHosts
10.0.0.0/2010.0.0.0 – 10.0.15.255us-east-1a~4,091Nodes + pod IPs
10.0.16.0/2010.0.16.0 – 10.0.31.255us-east-1b~4,091Nodes + pod IPs
10.0.32.0/2010.0.32.0 – 10.0.47.255us-east-1c~4,091Nodes + pod IPs
10.0.48-50.0/2410.0.48.0 – 10.0.50.2551a · 1b · 1c251 ea.ALB + NAT gateways

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.

Cost vs HA 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.
IP exhaustion Dense nodes burn subnet IPs fast. The failure looks like pods stuck 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.

eks.tfhcl
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"]
  }
}
SettingValueWhy
subnet_idsprivateNodes get no public IP; egress is via the per-AZ NAT.
authentication_modeAPIAccess via EKS Access Entries, not the legacy aws-auth ConfigMap.
endpoint_*_accesspublic + privateIn-VPC clients hit the private endpoint; public lets you run kubectl without a bastion.
enabled_log_typesall fiveFull control-plane audit trail to CloudWatch Logs.
create_auto_mode_iam_resourcestrueModule creates the cluster + node IAM roles (the CNI/Karpenter EC2 permissions).
node_poolsgeneral-purpose, systemThe two built-in Auto Mode pools; reserved names, scale from zero.
Lock the endpoint 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.

iam.tfhcl
# 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
}
AspectIRSA (old)Pod Identity (here)
OIDC providerRequired per clusterNot needed
Trust policyPer-role, pinned to one cluster's OIDCOne principal (pods.eks.amazonaws.com), reusable
BindingService-account annotationaws_eks_pod_identity_association
SA tokenProjected token mounted in the podNot used (automountServiceAccountToken: false)

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