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
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.
# Install Terraform with tfenvgit clone https://github.com/tfutils/tfenv.git ~/.tfenvexport PATH="$HOME/.tfenv/bin:$PATH"tfenv install latest && tfenv use latestterraform 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 | bashsudo apt-get install -y jq aws sts get-caller-identity# Expected: your account ID and an IAM identity that is not the root userCreate the state bucket. Versioning lets you recover an earlier state, and the public access block keeps it private:
export AWS_REGION=ap-south-1ACCOUNT_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:
mkdir eks-terraform && cd eks-terraform && git initprintf ".terraform/\n*.tfstate\n*.tfstate.*\n*.tfplan\nterraform.tfvars\n" > .gitignoreSecurityThe 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 MistakeKeeping 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 firstapply, 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:
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:
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:
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 = truekeeps the lab cheap but makes outbound traffic depend on one availability zone. For real production, set it tofalseso each zone has its own NAT.
Common MistakeForgetting the
kubernetes.io/role/elbandkubernetes.io/role/internal-elbsubnet 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:
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, ]}TipCheck which Kubernetes versions EKS currently supports with
aws eks describe-cluster-versions --query 'clusterVersions[].[clusterVersion,status]' --output tableand pick one in standard support. Versions that fall into extended support cost noticeably more per cluster hour.
SecurityAn unrestricted public endpoint is reachable from the whole internet. Authentication still protects it, but there is no reason to let everyone try. Set
admin_cidrsto your own/32(or your office range), or turn off the public endpoint and use a VPN or bastion.
Common MistakeDeleting 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:
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):
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:
cat > terraform.tfvars <<EOFadmin_cidrs = ["$(curl -s https://checkip.amazonaws.com | tr -d '\n')/32"]EOF terraform initterraform fmt && terraform validateterraform 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-ekskubectl get nodes -o wide# Expected: 2 nodes in Ready state with only INTERNAL-IP valuesTipSet
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 MistakeCreating the add-ons before any node exists. CoreDNS pods stay Pending with no node to run on, and the apply times out. The
depends_onon 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.
TipEKS 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:
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:
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:
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:
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:
terraform apply# Expected: the OIDC provider, role, policy and attachment are created cat > alb-values.yaml <<EOFclusterName: $(terraform output -raw cluster_name)region: ap-south-1vpcId: $(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 outThe 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.
SecurityThe
subcondition 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 MistakeApplying the Terraform before
iam_policy.jsonexists, or installing a controller whose version does not match the policy. The first fails onfile(), and the second producesAccessDeniederrors 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:
apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: 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: v1kind: Servicemetadata: name: webspec: selector: app: web ports: - port: 80 targetPort: 8080---apiVersion: networking.k8s.io/v1kind: Ingressmetadata: 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_CIDRspec: ingressClassName: alb rules: - http: paths: - path: / pathType: Prefix backend: service: name: web port: number: 80sed "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 MistakeSeeing an Ingress with an empty ADDRESS for minutes. Run
kubectl -n kube-system logs deploy/aws-load-balancer-controller.AccessDeniedpoints 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.
CLUSTER=prod-eks # 1. Nodes are Ready and system pods are healthykubectl get nodeskubectl 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 IMDSv2aws 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 keyaws eks describe-cluster --name $CLUSTER --query 'cluster.encryptionConfig'# Expected: resources ["secrets"] and your KMS key ARN # 4. Control plane logging is enabledaws 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 tokenkubectl -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 ALBHOST=$(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 agreeterraform plan -detailed-exitcodeecho "exit code: $?"# Expected: No changes. Exit code 0Clean 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.
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 versionsaws 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 MistakeRunning
terraform destroywhile 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.