Skip to main content

Deploy a Production EKS Cluster with Terraform

Build a private, encrypted EKS cluster with Terraform: VPC, managed nodes, IRSA and an ALB ingress, all in Git and ready to show in a portfolio.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project builds a production-style Amazon EKS cluster with Terraform: a VPC with private subnets, a control plane with encrypted secrets and audit logs, managed worker nodes, IAM Roles for Service Accounts (IRSA), and an AWS Load Balancer Controller that publishes apps through an ALB. You finish with one repository that creates the whole platform with a single apply, so a second environment is a variable change, not a week of clicking.

The problem this design solves

Picture a platform engineer at a payments company like Razorpay or Stripe. Developers want a Kubernetes cluster for staging next week and another for a new region next quarter. If the first cluster was built in the console, nobody can say exactly how, and the copies will quietly differ: one has public nodes, another has weaker IAM, a third was changed by hand during an incident.

Terraform makes the cluster a reviewed, versioned artefact. The design also builds in the security defaults that matter most: worker nodes with no public IPs, secrets encrypted with your own KMS key, IMDSv2 required on nodes, and narrowly scoped pod permissions through IRSA instead of broad node permissions.

What you will build

flowchart TD U["Your browser"] --> ALB["ALB in public subnets"] ALB --> P["Pods on private nodes"] P --> NAT["NAT gateway"] NAT --> IGW["Internet gateway"] CP["EKS control plane"] --> P IRSA["IRSA role"] --> LBC["Load Balancer Controller"] LBC --> ALB classDef aws fill:#F97316,color:#FFFFFF classDef k8s fill:#326CE5,color:#FFFFFF classDef sec fill:#8B5CF6,color:#FFFFFF classDef user fill:#475569,color:#FFFFFF class ALB,NAT,IGW aws class CP,P,LBC k8s class IRSA sec class U user

The Terraform code is split into small files, one per concern:

File Purpose
versions.tf Terraform and provider versions, remote state backend
variables.tf Inputs such as region, cluster name and node sizes
vpc.tf VPC, public and private subnets, NAT, route tables
eks.tf KMS key, log group, cluster IAM role and the EKS cluster
nodes.tf Node IAM role, launch template, managed node group, add-ons
irsa.tf OIDC provider that enables IRSA
alb-controller.tf IAM role and policy for the Load Balancer Controller
outputs.tf Values you read after apply

What you will have at the end

  • A Git repository that stands up the platform from scratch and destroys it cleanly
  • A cluster you can prove is private, encrypted and audited using the checks in Validation & Testing
  • A test app reachable through an ALB that only your IP can open

What this build leaves out

This is a strong foundation, not a finished platform. Before real production traffic you would also add node autoscaling (Cluster Autoscaler or Karpenter), the EBS CSI driver for persistent volumes, metrics and logging, network policies, backups and a CI pipeline that runs terraform plan on pull requests.

Prerequisites and cost

You need an AWS account with permission to create VPC, EKS, IAM, KMS and S3 resources, plus working knowledge of Terraform basics and kubectl. An EKS control plane, a NAT gateway and EC2 nodes all bill by the hour, so budget for a few hours of usage and run the destroy steps at the end. The lab uses ap-south-1 (Mumbai) and two availability zones.

Milestone 1: Prepare Tools and Remote State

This milestone installs the command-line tools and creates an S3 bucket that will hold Terraform state, so the cluster's record of truth is safe, versioned and shareable.

Terraform's state maps your code to real resources. For anything beyond a throwaway lab it belongs in a remote backend, because a lost laptop or a teammate's parallel apply would otherwise corrupt it. Terraform 1.10 and later can lock state with a lock file in the same S3 bucket (use_lockfile), so the old DynamoDB lock table is no longer needed.

Bash
# Install Terraform with tfenv
git clone https://github.com/tfutils/tfenv.git ~/.tfenv
export PATH="$HOME/.tfenv/bin:$PATH"
tfenv install latest && tfenv use latest
terraform version
# kubectl, Helm and jq (macOS: brew install kubectl helm jq)
curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl && sudo mv kubectl /usr/local/bin/
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
sudo apt-get install -y jq
aws sts get-caller-identity
# Expected: your account ID and an IAM identity that is not the root user

Create the state bucket. Versioning lets you recover an earlier state, and the public access block keeps it private:

Bash
export AWS_REGION=ap-south-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
STATE_BUCKET="eks-tfstate-${ACCOUNT_ID}"
aws s3api create-bucket --bucket "$STATE_BUCKET" --region "$AWS_REGION" \
--create-bucket-configuration LocationConstraint="$AWS_REGION"
aws s3api put-bucket-versioning --bucket "$STATE_BUCKET" \
--versioning-configuration Status=Enabled
aws s3api put-public-access-block --bucket "$STATE_BUCKET" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-bucket-encryption --bucket "$STATE_BUCKET" \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
echo "State bucket: $STATE_BUCKET"

Then create the project folder:

Bash
mkdir eks-terraform && cd eks-terraform && git init
printf ".terraform/\n*.tfstate\n*.tfstate.*\n*.tfplan\nterraform.tfvars\n" > .gitignore
Security

The state file for a cluster holds resource details and can include sensitive values. Keep the bucket private, enable versioning, and limit who can read it. Never store state in Git.

Common Mistake

Keeping state on your laptop for a shared cluster. The first time two people run apply, or your disk fails, Terraform loses track of real resources. Set up the remote backend before the first apply, not after.

Milestone 2: Build the VPC and Private Networking

This milestone creates the network the cluster lives in: public subnets for load balancers, private subnets for worker nodes, and the routes that connect them.

Worker nodes belong in private subnets, because they then have no public IP and cannot be reached from the internet. They still need outbound access to pull images and call AWS APIs, which is the job of a NAT gateway: it lets private resources start connections out while blocking connections in. Subnet tags tell the AWS Load Balancer Controller where to place internet-facing and internal load balancers.

Create versions.tf. Replace the bucket name with the value from the previous step:

HCL
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
backend "s3" {
bucket = "eks-tfstate-REPLACE_WITH_ACCOUNT_ID"
key = "production/eks/terraform.tfstate"
region = "ap-south-1"
encrypt = true
use_lockfile = true
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Project = var.cluster_name
Environment = "production"
ManagedBy = "terraform"
}
}
}

Create variables.tf:

HCL
variable "region" {
type = string
default = "ap-south-1"
}
variable "cluster_name" {
type = string
default = "prod-eks"
}
variable "kubernetes_version" {
description = "Pick a version that is still in standard support"
type = string
default = "1.35"
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "azs" {
type = list(string)
default = ["ap-south-1a", "ap-south-1b"]
}
variable "single_nat_gateway" {
description = "One shared NAT saves money; one per AZ survives an AZ outage"
type = bool
default = true
}
variable "admin_cidrs" {
description = "CIDR blocks allowed to reach the public Kubernetes API endpoint"
type = list(string)
}
variable "node_instance_types" {
type = list(string)
default = ["t3.medium"]
}
variable "node_capacity_type" {
description = "ON_DEMAND or SPOT"
type = string
default = "ON_DEMAND"
}
variable "node_min_size" {
type = number
default = 2
}
variable "node_desired_size" {
type = number
default = 2
}
variable "node_max_size" {
type = number
default = 4
}

Create vpc.tf:

HCL
locals {
nat_count = var.single_nat_gateway ? 1 : length(var.azs)
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.cluster_name}-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.cluster_name}-igw" }
}
resource "aws_subnet" "public" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
availability_zone = var.azs[count.index]
tags = {
Name = "${var.cluster_name}-public-${var.azs[count.index]}"
"kubernetes.io/role/elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_subnet" "private" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + 8)
availability_zone = var.azs[count.index]
tags = {
Name = "${var.cluster_name}-private-${var.azs[count.index]}"
"kubernetes.io/role/internal-elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_eip" "nat" {
count = local.nat_count
domain = "vpc"
tags = { Name = "${var.cluster_name}-nat-eip-${count.index}" }
}
resource "aws_nat_gateway" "main" {
count = local.nat_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = { Name = "${var.cluster_name}-nat-${count.index}" }
depends_on = [aws_internet_gateway.main]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "${var.cluster_name}-public" }
}
resource "aws_route_table_association" "public" {
count = length(var.azs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[var.single_nat_gateway ? 0 : count.index].id
}
tags = { Name = "${var.cluster_name}-private-${var.azs[count.index]}" }
}
resource "aws_route_table_association" "private" {
count = length(var.azs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
Tip

single_nat_gateway = true keeps the lab cheap but makes outbound traffic depend on one availability zone. For real production, set it to false so each zone has its own NAT.

Common Mistake

Forgetting the kubernetes.io/role/elb and kubernetes.io/role/internal-elb subnet tags. The load balancer controller uses them to discover subnets, and without them an Ingress stays in a pending state with no ALB created.

Milestone 3: Create the EKS Control Plane

This milestone creates the EKS cluster itself, with a KMS key for secrets, control plane logging, and access managed through EKS access entries.

The control plane (the API server and etcd) is run by AWS, but you decide how it is exposed and protected. Three choices matter. Envelope encryption with a KMS key encrypts Kubernetes Secrets in etcd with a key you control. Control plane logs send API and audit events to CloudWatch, which you will want during an incident. And limiting the public endpoint to admin_cidrs means only your network can even attempt to reach the API.

Create eks.tf:

HCL
resource "aws_kms_key" "secrets" {
description = "EKS secrets encryption for ${var.cluster_name}"
deletion_window_in_days = 7
enable_key_rotation = true
}
resource "aws_cloudwatch_log_group" "cluster" {
name = "/aws/eks/${var.cluster_name}/cluster"
retention_in_days = 30
}
resource "aws_iam_role" "cluster" {
name = "${var.cluster_name}-cluster-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "eks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "cluster" {
role = aws_iam_role.cluster.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}
resource "aws_eks_cluster" "main" {
name = var.cluster_name
version = var.kubernetes_version
role_arn = aws_iam_role.cluster.arn
# Access is granted through EKS access entries, not the old aws-auth ConfigMap
access_config {
authentication_mode = "API"
bootstrap_cluster_creator_admin_permissions = true
}
vpc_config {
subnet_ids = aws_subnet.private[*].id
endpoint_private_access = true
endpoint_public_access = true
public_access_cidrs = var.admin_cidrs
}
encryption_config {
resources = ["secrets"]
provider {
key_arn = aws_kms_key.secrets.arn
}
}
enabled_cluster_log_types = [
"api", "audit", "authenticator", "controllerManager", "scheduler"
]
depends_on = [
aws_iam_role_policy_attachment.cluster,
aws_cloudwatch_log_group.cluster,
]
}
Tip

Check which Kubernetes versions EKS currently supports with aws eks describe-cluster-versions --query 'clusterVersions[].[clusterVersion,status]' --output table and pick one in standard support. Versions that fall into extended support cost noticeably more per cluster hour.

Security

An unrestricted public endpoint is reachable from the whole internet. Authentication still protects it, but there is no reason to let everyone try. Set admin_cidrs to your own /32 (or your office range), or turn off the public endpoint and use a VPN or bastion.

Common Mistake

Deleting a KMS key that an existing cluster uses for secrets encryption. The cluster can no longer read its own secrets. Keep the key for the life of the cluster, which is why Terraform manages both together.

Milestone 4: Add Managed Node Groups and Add-ons

This milestone adds the worker nodes and the core cluster add-ons, then applies everything so you have a working cluster.

A managed node group lets EKS create, patch and replace EC2 nodes in an Auto Scaling group. A launch template is where you set options the node group does not expose, such as requiring IMDSv2 (which blocks a common credential-theft attack through the instance metadata service) and encrypting the root disk. The add-ons vpc-cni, kube-proxy and coredns provide pod networking and DNS.

Create nodes.tf:

HCL
resource "aws_iam_role" "node" {
name = "${var.cluster_name}-node-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "node" {
for_each = toset([
"AmazonEKSWorkerNodePolicy",
"AmazonEKS_CNI_Policy",
"AmazonEC2ContainerRegistryReadOnly",
])
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/${each.key}"
}
resource "aws_launch_template" "node" {
name_prefix = "${var.cluster_name}-node-"
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # IMDSv2 only
http_put_response_hop_limit = 1 # pods cannot reach node credentials
}
block_device_mappings {
device_name = "/dev/xvda"
ebs {
volume_size = 50
volume_type = "gp3"
encrypted = true
delete_on_termination = true
}
}
tag_specifications {
resource_type = "instance"
tags = { Name = "${var.cluster_name}-node" }
}
}
resource "aws_eks_node_group" "general" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "general"
node_role_arn = aws_iam_role.node.arn
subnet_ids = aws_subnet.private[*].id
ami_type = "AL2023_x86_64_STANDARD"
instance_types = var.node_instance_types
capacity_type = var.node_capacity_type
scaling_config {
min_size = var.node_min_size
desired_size = var.node_desired_size
max_size = var.node_max_size
}
update_config {
max_unavailable = 1
}
launch_template {
id = aws_launch_template.node.id
version = aws_launch_template.node.latest_version
}
labels = { workload = "general" }
# An autoscaler may change the desired size, so Terraform should not fight it
lifecycle {
ignore_changes = [scaling_config[0].desired_size]
}
depends_on = [aws_iam_role_policy_attachment.node]
}
resource "aws_eks_addon" "core" {
for_each = toset(["vpc-cni", "kube-proxy", "coredns"])
cluster_name = aws_eks_cluster.main.name
addon_name = each.key
resolve_conflicts_on_create = "OVERWRITE"
resolve_conflicts_on_update = "PRESERVE"
depends_on = [aws_eks_node_group.general]
}

Create outputs.tf for now (you will extend it later):

HCL
output "cluster_name" {
value = aws_eks_cluster.main.name
}
output "cluster_endpoint" {
value = aws_eks_cluster.main.endpoint
}
output "vpc_id" {
value = aws_vpc.main.id
}

Provide your values and apply:

Bash
cat > terraform.tfvars <<EOF
admin_cidrs = ["$(curl -s https://checkip.amazonaws.com | tr -d '\n')/32"]
EOF
terraform init
terraform fmt && terraform validate
terraform plan -out=tfplan
# Read the plan. Look for the VPC, cluster, node group and add-ons.
terraform apply tfplan
# This takes roughly 15 to 20 minutes, mostly the control plane
aws eks update-kubeconfig --region ap-south-1 --name prod-eks
kubectl get nodes -o wide
# Expected: 2 nodes in Ready state with only INTERNAL-IP values
Tip

Set node_capacity_type = "SPOT" for non-production node groups to lower cost, then compare your actual bill to On-Demand rather than assuming a number. Keep On-Demand for workloads that cannot tolerate interruption.

Common Mistake

Creating the add-ons before any node exists. CoreDNS pods stay Pending with no node to run on, and the apply times out. The depends_on on the node group avoids that.

Milestone 5: Enable IRSA and the Load Balancer Controller

This milestone lets individual pods assume narrowly scoped AWS roles, then uses that to install the AWS Load Balancer Controller so a Kubernetes Ingress creates a real ALB.

Without IRSA, any pod on a node can use the node's IAM role, so one compromised container inherits every permission the node has. IRSA (IAM Roles for Service Accounts) fixes this: the cluster has an OIDC identity provider, and an IAM role trusts one specific Kubernetes service account. Only pods using that service account receive its credentials. The Load Balancer Controller needs permissions to create ALBs, so it is the right first user of IRSA.

Tip

EKS Pod Identity is a newer alternative to IRSA with simpler setup. IRSA is still widely used in existing clusters and in interviews, which is why this project teaches it first.

Create irsa.tf:

HCL
resource "aws_iam_openid_connect_provider" "eks" {
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
client_id_list = ["sts.amazonaws.com"]
}
locals {
oidc_host = replace(aws_eks_cluster.main.identity[0].oidc[0].issuer, "https://", "")
}

Download the controller's IAM policy. Matching the policy to the controller version keeps permissions in sync:

Bash
helm repo add eks https://aws.github.io/eks-charts && helm repo update
LBC_VERSION=$(helm search repo eks/aws-load-balancer-controller -o json | jq -r '.[0].app_version')
echo "Controller version: $LBC_VERSION"
curl -fsSL -o iam_policy.json \
"https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/${LBC_VERSION}/docs/install/iam_policy.json"

Create alb-controller.tf:

HCL
data "aws_iam_policy_document" "alb_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.eks.arn]
}
# Only this service account may assume the role
condition {
test = "StringEquals"
variable = "${local.oidc_host}:sub"
values = ["system:serviceaccount:kube-system:aws-load-balancer-controller"]
}
condition {
test = "StringEquals"
variable = "${local.oidc_host}:aud"
values = ["sts.amazonaws.com"]
}
}
}
resource "aws_iam_role" "alb_controller" {
name = "${var.cluster_name}-alb-controller"
assume_role_policy = data.aws_iam_policy_document.alb_assume.json
}
resource "aws_iam_policy" "alb_controller" {
name = "${var.cluster_name}-alb-controller"
policy = file("${path.module}/iam_policy.json")
}
resource "aws_iam_role_policy_attachment" "alb_controller" {
role = aws_iam_role.alb_controller.name
policy_arn = aws_iam_policy.alb_controller.arn
}

Add one output to outputs.tf:

HCL
output "alb_controller_role_arn" {
value = aws_iam_role.alb_controller.arn
}

Apply, then install the controller through Helm with the role attached to its service account:

Bash
terraform apply
# Expected: the OIDC provider, role, policy and attachment are created
cat > alb-values.yaml <<EOF
clusterName: $(terraform output -raw cluster_name)
region: ap-south-1
vpcId: $(terraform output -raw vpc_id)
serviceAccount:
create: true
name: aws-load-balancer-controller
annotations:
eks.amazonaws.com/role-arn: $(terraform output -raw alb_controller_role_arn)
EOF
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
-n kube-system -f alb-values.yaml
kubectl -n kube-system rollout status deploy/aws-load-balancer-controller
# Expected: successfully rolled out

The values file sets region and vpcId explicitly. The node launch template allows only one network hop to the metadata service, so pods cannot discover them on their own, which is the behaviour you want.

Security

The sub condition in the trust policy is what makes IRSA safe. Without it, any service account in the cluster could assume the role. Always scope the condition to one namespace and one service account.

Common Mistake

Applying the Terraform before iam_policy.json exists, or installing a controller whose version does not match the policy. The first fails on file(), and the second produces AccessDenied errors in the controller logs when it tries to create the ALB. Download the policy for the same version you install.

Milestone 6: Deploy a Test App Behind an ALB

This milestone deploys a small web app, exposes it through an Ingress, and proves the full path from browser to pod works.

When you create an Ingress with ingressClassName: alb, the controller watches it, calls the AWS API using its IRSA role, and builds an Application Load Balancer. With target-type: ip, traffic goes straight to pod IPs. The inbound-cidrs annotation restricts who can reach the ALB, in the same spirit as a security group.

Create web.yaml:

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:stable-alpine
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /
port: 8080
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web
ports:
- port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/inbound-cidrs: MY_IP_CIDR
spec:
ingressClassName: alb
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
Bash
sed "s#MY_IP_CIDR#$(curl -s https://checkip.amazonaws.com | tr -d '\n')/32#" web.yaml | kubectl apply -f -
kubectl get pods -l app=web
# Expected: 2 pods Running
kubectl get ingress web --watch
# Wait until ADDRESS shows an ALB hostname (usually 2 to 3 minutes), then Ctrl+C
Resource What it proves
Deployment Pods schedule onto the private nodes
Service In-cluster routing to the pods
Ingress The controller can create an ALB through IRSA
Common Mistake

Seeing an Ingress with an empty ADDRESS for minutes. Run kubectl -n kube-system logs deploy/aws-load-balancer-controller. AccessDenied points to the IAM policy or role annotation, and "couldn't auto-discover subnets" points to missing subnet tags.

Validation & Testing

This final step verifies that the cluster works and that its security claims are true, then removes everything. Run the checks in order.

Bash
CLUSTER=prod-eks
# 1. Nodes are Ready and system pods are healthy
kubectl get nodes
kubectl get pods -n kube-system
# Expected: nodes Ready; coredns, aws-node, kube-proxy and the controller Running
# 2. Nodes have no public IP and require IMDSv2
aws ec2 describe-instances \
--filters "Name=tag:eks:cluster-name,Values=$CLUSTER" "Name=instance-state-name,Values=running" \
--query 'Reservations[].Instances[].[InstanceId,PublicIpAddress,MetadataOptions.HttpTokens]' \
--output table
# Expected: PublicIpAddress is None, HttpTokens is required
# 3. Secrets are encrypted with your KMS key
aws eks describe-cluster --name $CLUSTER --query 'cluster.encryptionConfig'
# Expected: resources ["secrets"] and your KMS key ARN
# 4. Control plane logging is enabled
aws eks describe-cluster --name $CLUSTER \
--query 'cluster.logging.clusterLogging[?enabled==`true`].types[]'
# Expected: api, audit, authenticator, controllerManager, scheduler
# 5. IRSA is wired up: the pod received a web identity token
kubectl -n kube-system get pod -l app.kubernetes.io/name=aws-load-balancer-controller \
-o jsonpath='{.items[0].spec.containers[0].env[*].name}'
# Expected: output includes AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE
# 6. The app answers through the ALB
HOST=$(kubectl get ingress web -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
curl -s -o /dev/null -w "%{http_code}\n" "http://$HOST"
# Expected: 200 (from a different network the request should time out)
# 7. Terraform and reality agree
terraform plan -detailed-exitcode
echo "exit code: $?"
# Expected: No changes. Exit code 0

Clean up

Delete Kubernetes resources that create AWS resources first. If you destroy the VPC while the ALB still exists, the destroy hangs on leftover network interfaces and security groups.

Bash
kubectl delete -f web.yaml
# Wait until the ALB disappears (no output from this command)
aws elbv2 describe-load-balancers --query 'LoadBalancers[].LoadBalancerName'
helm uninstall aws-load-balancer-controller -n kube-system
terraform destroy
# Type yes. Expected: Destroy complete!
# Finally remove the state bucket, including old versions
aws s3api delete-objects --bucket "$STATE_BUCKET" --delete "$(aws s3api list-object-versions \
--bucket "$STATE_BUCKET" \
--query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}' --output json)"
aws s3api delete-bucket --bucket "$STATE_BUCKET"
Common Mistake

Running terraform destroy while the ALB from your Ingress still exists. Terraform did not create that ALB, so it cannot delete it, and the VPC destroy fails with dependency errors. Delete the Ingress first.

Commit the repository with a README that shows your validation output. A reviewer can see at a glance that the cluster is private, encrypted, audited and reproducible.

Found a mistake, an outdated step or a broken link?