Building a Production EKS Cluster from Scratch — Everything I Wish Someone Had Told Me

Tisighe Livinstone

Tisighe Livinstone

12 June 2024·10 min read
Building a Production EKS Cluster from Scratch — Everything I Wish Someone Had Told Me

The first EKS cluster I built took three days and broke twice. The second took half a day. The third took 45 minutes because I finally had a repeatable process. This is that process.

This is Part 2 of the Production EKS on AWS series. If you have not done Part 1 yet, start there — it provisions the VPC and subnets this cluster deploys into. Both parts live in the eks-terraform-foundation repository.

EKS Terraform Foundation full architecture diagram

What You Need

Repository Structure

Part 2 adds three new modules on top of the networking module from Part 1:

eks-terraform-foundation/
├── bootstrap/                    # Run once — S3 + DynamoDB for state (done in Part 1)
├── modules/
│   ├── networking/               # Part 1 — VPC, subnets, NAT gateways
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf            # vpc_id and subnet IDs passed into EKS below
│   ├── eks/                      # Part 2 — EKS cluster, OIDC provider, IRSA
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf            # cluster_name, endpoint, oidc_provider_arn
│   ├── node-groups/              # Part 2 — system (tainted) + application node groups
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── addons/                   # Part 2 — LB Controller, Cluster Autoscaler, Metrics Server
│       ├── main.tf
│       └── variables.tf
└── environments/
    ├── dev/                      # 2 AZs, t3.medium, min 1 node
    ├── staging/                  # 2 AZs, t3.medium, min 1 node
    └── production/               # 3 AZs, m5.large, 1-10 nodes

Before You Start

The VPC, subnets, and NAT Gateways from Part 1 are already running. The EKS module here does not recreate any of that — it just takes the VPC ID and subnet IDs as inputs and builds on top of them. One of the things I got wrong early on was putting networking and cluster config in the same place. Separating them means you can change cluster configuration without touching the network, and the other way around.

Step 1: The EKS Cluster Module

The cluster goes into the private subnets — nodes should never have public IPs. Two things worth calling out in this config: IRSA and the endpoint settings. IRSA means each add-on gets its own scoped IAM role via a service account annotation instead of inheriting the broad node instance profile. The endpoint settings keep kubelet-to-control-plane traffic inside the VPC while still letting kubectl work from your laptop.

Create modules/eks/main.tf:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.0"

  cluster_name    = "${var.environment}-${var.project_name}"
  cluster_version = var.cluster_version

  # Receives VPC and subnet IDs from the networking module output in Part 1
  vpc_id     = var.vpc_id
  subnet_ids = var.private_subnet_ids   # nodes go into private subnets only

  # Public access lets kubectl work from your laptop
  # Private access keeps kubelet-to-control-plane traffic inside the VPC
  cluster_endpoint_public_access       = true
  cluster_endpoint_private_access      = true
  cluster_endpoint_public_access_cidrs = var.allowed_cidrs

  # IRSA: pods assume IAM roles via service account annotation
  # instead of inheriting the broad node instance profile
  enable_irsa = true

  # Managed add-ons — AWS handles version upgrades
  cluster_addons = {
    coredns            = { most_recent = true }
    kube-proxy         = { most_recent = true }
    vpc-cni            = { most_recent = true }
  }

  cluster_enabled_log_types = ["api", "audit", "authenticator"]

  tags = var.tags
}

Create modules/eks/variables.tf:

variable "project_name"       { type = string }
variable "environment"        { type = string }
variable "cluster_version"    { type = string; default = "1.36" }
variable "vpc_id"             { type = string }   # from networking module output
variable "private_subnet_ids" { type = list(string) }   # from networking module output
variable "allowed_cidrs"      { type = list(string); default = ["0.0.0.0/0"] }
variable "tags"               { type = map(string); default = {} }

Create modules/eks/outputs.tf:

output "cluster_name"     { value = module.eks.cluster_name }
output "cluster_endpoint" { value = module.eks.cluster_endpoint }

# Used by the Helm provider to authenticate against the cluster
output "cluster_certificate_authority_data" {
  value = module.eks.cluster_certificate_authority_data
}

# Used to create IRSA roles for the add-ons
output "oidc_provider_arn" { value = module.eks.oidc_provider_arn }

Step 2: Node Groups

I learned the hard way why you need separate node groups. Before we split them, a traffic spike would consume all resources on a shared node, CoreDNS would get evicted, and DNS would break cluster-wide. Every pod that tried to make a network call would fail. The system node taint prevents application pods from landing on system nodes at all — so a spike in application traffic cannot touch the infrastructure running everything else.

Create modules/node-groups/main.tf:

# IAM role for all nodes — gives them permissions to join the cluster
# and pull images from ECR
data "aws_iam_policy_document" "node_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "node" {
  name               = "${var.environment}-${var.project_name}-node-role"
  assume_role_policy = data.aws_iam_policy_document.node_assume_role.json
  tags               = var.tags
}

resource "aws_iam_role_policy_attachment" "node_worker" {
  role       = aws_iam_role.node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}
resource "aws_iam_role_policy_attachment" "node_cni" {
  role       = aws_iam_role.node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}
resource "aws_iam_role_policy_attachment" "node_ecr" {
  role       = aws_iam_role.node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
resource "aws_iam_role_policy_attachment" "node_ssm" {
  role       = aws_iam_role.node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# System node group — runs cluster infrastructure only
# The taint ensures application pods cannot be scheduled here
resource "aws_eks_node_group" "system" {
  cluster_name    = var.cluster_name
  node_group_name = "${var.environment}-${var.project_name}-system"
  node_role_arn   = aws_iam_role.node.arn
  subnet_ids      = var.private_subnet_ids
  instance_types  = var.system_instance_types

  scaling_config {
    desired_size = var.system_desired_size
    min_size     = var.system_min_size
    max_size     = var.system_max_size
  }

  taint {
    key    = "dedicated"
    value  = "system"
    effect = "NO_SCHEDULE"
  }

  labels = { role = "system" }
  tags   = var.tags

  depends_on = [
    aws_iam_role_policy_attachment.node_worker,
    aws_iam_role_policy_attachment.node_cni,
    aws_iam_role_policy_attachment.node_ecr,
    aws_iam_role_policy_attachment.node_ssm,
  ]
}

# Application node group — runs workloads
# Tags allow Cluster Autoscaler to discover and manage this group
resource "aws_eks_node_group" "application" {
  cluster_name    = var.cluster_name
  node_group_name = "${var.environment}-${var.project_name}-application"
  node_role_arn   = aws_iam_role.node.arn
  subnet_ids      = var.private_subnet_ids
  instance_types  = var.application_instance_types

  scaling_config {
    desired_size = var.application_desired_size
    min_size     = var.application_min_size
    max_size     = var.application_max_size
  }

  labels = { role = "application" }

  tags = merge(var.tags, {
    "k8s.io/cluster-autoscaler/enabled"             = "true"
    "k8s.io/cluster-autoscaler/${var.cluster_name}" = "owned"
  })

  depends_on = [
    aws_iam_role_policy_attachment.node_worker,
    aws_iam_role_policy_attachment.node_cni,
    aws_iam_role_policy_attachment.node_ecr,
    aws_iam_role_policy_attachment.node_ssm,
  ]
}
image.png

Step 3: Add-ons

Each add-on gets its own IRSA role scoped to exactly what it needs — the Load Balancer Controller can create and manage load balancers, the Cluster Autoscaler can describe and modify Auto Scaling groups, and that is it. No shared node credentials that a compromised pod could use to do something far beyond what it should be able to do.

Create modules/addons/main.tf:

terraform {
  required_providers {
    helm = { source = "hashicorp/helm"; version = "~> 2.0" }
  }
}

# Creates ALBs and NLBs from Kubernetes Ingress and Service objects
resource "helm_release" "aws_load_balancer_controller" {
  name       = "aws-load-balancer-controller"
  repository = "https://aws.github.io/eks-charts"
  chart      = "aws-load-balancer-controller"
  namespace  = "kube-system"
  version    = "1.8.1"

  set { name = "clusterName";           value = var.cluster_name }
  set { name = "serviceAccount.create"; value = "true" }
  set {
    name  = "serviceAccount.annotations.eks\.amazonaws\.com/role-arn"
    value = var.lb_controller_role_arn   # IRSA role — scoped to LB permissions only
  }
}

# Scales application node group up when pods are pending, down when nodes are idle
resource "helm_release" "cluster_autoscaler" {
  name       = "cluster-autoscaler"
  repository = "https://kubernetes.github.io/autoscaler"
  chart      = "cluster-autoscaler"
  namespace  = "kube-system"
  version    = "9.37.0"

  set { name = "autoDiscovery.clusterName"; value = var.cluster_name }
  set { name = "awsRegion";                 value = var.region }
  set {
    name  = "rbac.serviceAccount.annotations.eks\.amazonaws\.com/role-arn"
    value = var.cluster_autoscaler_role_arn   # IRSA role — scoped to autoscaling permissions
  }
}

# Enables kubectl top pods/nodes and the Horizontal Pod Autoscaler
resource "helm_release" "metrics_server" {
  name       = "metrics-server"
  repository = "https://kubernetes-sigs.github.io/metrics-server/"
  chart      = "metrics-server"
  namespace  = "kube-system"
  version    = "3.12.1"
}

Step 4: Wire Up the Production Environment

The environment file is where all four modules connect. Networking runs first, outputs the VPC and subnet IDs, and those flow directly into the EKS module. Node groups wait for the cluster to exist. Add-ons wait for nodes to be ready before Helm tries to install anything. The order matters and the depends_on blocks enforce it.

Create environments/production/main.tf:

terraform {
  required_version = ">= 1.5"
  required_providers {
    aws  = { source = "hashicorp/aws";  version = "~> 5.0" }
    helm = { source = "hashicorp/helm"; version = "~> 2.0" }
  }

  backend "s3" {
    bucket         = "livinstone-infra-tfstate-506456084401"  # from bootstrap output
    key            = "production/terraform.tfstate"
    region         = "eu-west-1"
    encrypt        = true
    dynamodb_table = "livinstone-infra-tfstate-lock"
  }
}

provider "aws" {
  region = var.region
  default_tags { tags = local.tags }
}

# Helm provider authenticates against the cluster using the endpoint and cert from the EKS module
provider "helm" {
  kubernetes {
    host                   = module.eks.cluster_endpoint
    cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args        = ["eks", "get-token", "--cluster-name", module.eks.cluster_name]
    }
  }
}

locals {
  tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

# Part 1 — Networking (VPC, subnets, NAT gateways)
module "networking" {
  source               = "../../modules/networking"
  project_name         = var.project_name
  environment          = var.environment
  vpc_cidr             = var.vpc_cidr
  availability_zones   = var.availability_zones
  private_subnet_cidrs = var.private_subnet_cidrs
  public_subnet_cidrs  = var.public_subnet_cidrs
  tags                 = local.tags
}

# Part 2 — EKS control plane (uses vpc_id and subnet IDs from networking above)
module "eks" {
  source             = "../../modules/eks"
  project_name       = var.project_name
  environment        = var.environment
  cluster_version    = var.cluster_version
  vpc_id             = module.networking.vpc_id
  private_subnet_ids = module.networking.private_subnet_ids
  allowed_cidrs      = var.allowed_cidrs
  tags               = local.tags
}

# Part 2 — System and application node groups
module "node_groups" {
  source                     = "../../modules/node-groups"
  project_name               = var.project_name
  environment                = var.environment
  cluster_name               = module.eks.cluster_name
  private_subnet_ids         = module.networking.private_subnet_ids
  application_instance_types = var.application_instance_types
  application_min_size       = var.application_min_size
  application_max_size       = var.application_max_size
  application_desired_size   = var.application_desired_size
  tags                       = local.tags
}

# IRSA roles — each add-on gets its own scoped IAM role via the cluster OIDC provider
module "lb_controller_irsa" {
  source                                 = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version                                = "~> 5.0"
  role_name                              = "${var.environment}-${var.project_name}-lb-controller"
  attach_load_balancer_controller_policy = true
  oidc_providers = {
    main = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["kube-system:aws-load-balancer-controller"]
    }
  }
}

module "cluster_autoscaler_irsa" {
  source                           = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version                          = "~> 5.0"
  role_name                        = "${var.environment}-${var.project_name}-cluster-autoscaler"
  attach_cluster_autoscaler_policy = true
  cluster_autoscaler_cluster_names = [module.eks.cluster_name]
  oidc_providers = {
    main = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["kube-system:cluster-autoscaler-aws-cluster-autoscaler"]
    }
  }
}

# Part 2 — Add-ons (waits for node groups to be ready before installing)
module "addons" {
  source                      = "../../modules/addons"
  cluster_name                = module.eks.cluster_name
  region                      = var.region
  lb_controller_role_arn      = module.lb_controller_irsa.iam_role_arn
  cluster_autoscaler_role_arn = module.cluster_autoscaler_irsa.iam_role_arn
  depends_on                  = [module.node_groups]
}

Step 5: Deploy

cd environments/production

# Download providers and modules
terraform init

# Review the full plan — should show ~50 resources to create
terraform plan -out=prod.tfplan

# Apply — takes 15-20 minutes
# EKS control plane creation is the slowest part
terraform apply prod.tfplan
image.png

Step 6: Configure kubectl and Verify

image.png
# Update kubeconfig to point to the new cluster
aws eks update-kubeconfig --name production-livinstone-infra --region eu-west-1

# Verify all nodes are Ready
kubectl get nodes -o wide
# NAME                                      STATUS   ROLES    AGE
# ip-10-0-1-xx.eu-west-1.compute.internal  Ready    none     3m   ← system node
# ip-10-0-2-xx.eu-west-1.compute.internal  Ready    none     3m   ← system node
# ip-10-0-1-yy.eu-west-1.compute.internal  Ready    none     3m   ← application node
# ip-10-0-2-yy.eu-west-1.compute.internal  Ready    none     3m   ← application node

# Verify all add-ons are running
kubectl get pods -n kube-system
# coredns                           Running  ← on system nodes
# kube-proxy                        Running
# aws-node (VPC CNI)                Running
# ebs-csi-controller                Running
# aws-load-balancer-controller      Running  ← IRSA role attached
# cluster-autoscaler                Running  ← IRSA role attached
# metrics-server                    Running
image.png

Complete source code: eks-terraform-foundation. This cluster is the foundation for the Argo CD GitOps setup and the observability stack.

Tisighe Livinstone

Tisighe Livinstone

Cloud & DevOps Engineer

Writing about real infrastructure challenges — Kubernetes, Terraform, GitOps, observability, and cloud security. Based on things I've actually built and broken in production.

Comments

All comments are reviewed before appearing.

Leave a Comment