IaC AWS

Terraform Module: AWS EKS Cluster — a hardened control plane with IRSA, KMS envelope encryption, and API access entries

Quick take — Reusable Terraform module for an AWS EKS cluster on hashicorp/aws ~> 5.0: cluster IAM role, OIDC/IRSA provider, KMS secret encryption, control-plane logging, and the new API access-entry auth mode. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "eks" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-eks?ref=v1.0.0"

  cluster_name       = "..."           # Cluster name; also derives the IAM role, KMS alias, and…
  kubernetes_version = "..."           # EKS minor version (e.g. `1.30`); pinned, validated agai…
  subnet_ids         = ["...", "..."]  # Control-plane subnets spanning >=2 AZs (private preferr…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon EKS is the managed Kubernetes control plane on AWS. The aws_eks_cluster resource provisions the part AWS runs for you: the API server, etcd, the scheduler, and the controller-manager, all spread across three Availability Zones and patched by AWS. What it does not give you for free is everything that turns that bare control plane into something you can safely run production on — and that gap is exactly why a wrapper module pays off.

A correct production EKS cluster is never just one resource. The control plane needs a cluster IAM role with the AmazonEKSClusterPolicy attached before the API server will start. It needs an OIDC identity provider registered in IAM so that pods can assume IAM roles directly (IRSA / EKS Pod Identity) instead of sharing a fat node instance profile. It should encrypt Kubernetes Secrets at rest with a customer-managed KMS key (envelope encryption) rather than the default platform key. It should ship control-plane audit and authenticator logs to CloudWatch so you have a forensic trail. And on ~> 5.0 of the AWS provider it should use the modern API authentication mode with access entries — the supported replacement for hand-editing the brittle aws-auth ConfigMap that every legacy cluster eventually corrupts.

Wiring those five concerns together by hand in every stack is where drift creeps in: one cluster forgets the OIDC provider and falls back to node-role credentials, another runs public endpoint access wide open to 0.0.0.0/0, a third never enables the audit log type and fails its first security review. This module fixes the contract once. It exposes a small, var-driven surface — name, version, subnets, endpoint policy, who gets admin — and bakes in the safe defaults (private endpoint, IRSA on, KMS encryption, audit logging, API access mode) so every consuming team gets a hardened control plane without copying 200 lines of HCL.

When to use it

Reach for something else when the workload is a better fit for ECS on Fargate (you want containers without owning Kubernetes), App Runner / Lambda (request- or event-driven, no orchestrator), or when you only need a throwaway cluster for a workshop — eksctl is faster for ephemeral experiments. This module is for clusters you intend to keep, observe, and answer audit questions about. Note that it provisions the control plane and its identity/encryption scaffolding only; node groups, the EBS CSI driver, and add-ons are deliberately separate modules so they can roll independently of the cluster.

Module structure

terraform-module-aws-eks/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }
}

main.tf

data "aws_partition" "current" {}

locals {
  name = var.cluster_name

  tags = merge(
    var.tags,
    {
      Name      = local.name
      ManagedBy = "terraform"
    },
  )

  # Log types EKS can ship to CloudWatch; "audit" + "authenticator" are the
  # security-relevant ones and are enabled by default below.
  enabled_log_types = var.enabled_cluster_log_types
}

# ---------------------------------------------------------------------------
# Cluster IAM role — the control plane assumes this to manage AWS resources.
# AmazonEKSClusterPolicy must be attached before the API server will start.
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "cluster_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["eks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "cluster" {
  name                  = "${local.name}-cluster-role"
  assume_role_policy    = data.aws_iam_policy_document.cluster_assume_role.json
  force_detach_policies = true
  tags                  = local.tags
}

resource "aws_iam_role_policy_attachment" "cluster_policy" {
  role       = aws_iam_role.cluster.name
  policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSClusterPolicy"
}

# ---------------------------------------------------------------------------
# Customer-managed KMS key for envelope encryption of Kubernetes Secrets.
# ---------------------------------------------------------------------------
resource "aws_kms_key" "eks" {
  count = var.create_kms_key ? 1 : 0

  description             = "Envelope encryption key for EKS cluster ${local.name} secrets"
  deletion_window_in_days = var.kms_key_deletion_window
  enable_key_rotation     = true
  tags                    = local.tags
}

resource "aws_kms_alias" "eks" {
  count = var.create_kms_key ? 1 : 0

  name          = "alias/eks/${local.name}"
  target_key_id = aws_kms_key.eks[0].key_id
}

# ---------------------------------------------------------------------------
# The EKS control plane.
# ---------------------------------------------------------------------------
resource "aws_eks_cluster" "this" {
  name                          = local.name
  version                       = var.kubernetes_version
  role_arn                      = aws_iam_role.cluster.arn
  enabled_cluster_log_types     = local.enabled_log_types
  bootstrap_self_managed_addons = var.bootstrap_self_managed_addons

  vpc_config {
    subnet_ids              = var.subnet_ids
    endpoint_private_access = var.endpoint_private_access
    endpoint_public_access  = var.endpoint_public_access
    public_access_cidrs     = var.endpoint_public_access ? var.public_access_cidrs : null
    security_group_ids      = var.cluster_security_group_ids
  }

  # Modern auth: API access entries instead of the aws-auth ConfigMap.
  access_config {
    authentication_mode                         = var.authentication_mode
    bootstrap_cluster_creator_admin_permissions = var.bootstrap_cluster_creator_admin_permissions
  }

  # Restrict the Service/Pod CIDR ranges Kubernetes hands out (optional).
  dynamic "kubernetes_network_config" {
    for_each = var.service_ipv4_cidr == null ? [] : [1]
    content {
      service_ipv4_cidr = var.service_ipv4_cidr
    }
  }

  # Envelope-encrypt Secrets with the customer-managed key when available.
  dynamic "encryption_config" {
    for_each = local.kms_key_arn == null ? [] : [1]
    content {
      provider {
        key_arn = local.kms_key_arn
      }
      resources = ["secrets"]
    }
  }

  tags = local.tags

  # The control plane and its log types are managed here; do not let an
  # out-of-band change to the cluster role policy attachment race creation.
  depends_on = [
    aws_iam_role_policy_attachment.cluster_policy,
  ]
}

locals {
  # Use the created key, or a caller-supplied ARN, or nothing.
  kms_key_arn = var.create_kms_key ? aws_kms_key.eks[0].arn : var.kms_key_arn
}

# ---------------------------------------------------------------------------
# IRSA: register the cluster's OIDC issuer as an IAM identity provider so
# pods can assume IAM roles via a federated web-identity token.
# ---------------------------------------------------------------------------
data "tls_certificate" "oidc" {
  count = var.enable_irsa ? 1 : 0
  url   = aws_eks_cluster.this.identity[0].oidc[0].issuer
}

resource "aws_iam_openid_connect_provider" "oidc" {
  count = var.enable_irsa ? 1 : 0

  url             = aws_eks_cluster.this.identity[0].oidc[0].issuer
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.oidc[0].certificates[0].sha1_fingerprint]
  tags            = local.tags
}

# ---------------------------------------------------------------------------
# Access entries: grant principals cluster access through the API, mapped to
# EKS access policies (e.g. cluster admin) instead of RBAC ConfigMap edits.
# ---------------------------------------------------------------------------
resource "aws_eks_access_entry" "this" {
  for_each = var.authentication_mode == "CONFIG_MAP" ? {} : var.access_entries

  cluster_name      = aws_eks_cluster.this.name
  principal_arn     = each.value.principal_arn
  kubernetes_groups = each.value.kubernetes_groups
  type              = each.value.type
  tags              = local.tags
}

resource "aws_eks_access_policy_association" "this" {
  for_each = {
    for assoc in flatten([
      for k, entry in var.access_entries : [
        for policy_arn in entry.policy_associations : {
          key           = "${k}:${policy_arn.policy_arn}"
          principal_arn = entry.principal_arn
          policy_arn    = policy_arn.policy_arn
          access_scope  = policy_arn.access_scope
        }
      ] if var.authentication_mode != "CONFIG_MAP"
    ]) : assoc.key => assoc
  }

  cluster_name  = aws_eks_cluster.this.name
  principal_arn = each.value.principal_arn
  policy_arn    = each.value.policy_arn

  access_scope {
    type       = each.value.access_scope.type
    namespaces = each.value.access_scope.type == "namespace" ? each.value.access_scope.namespaces : null
  }

  depends_on = [aws_eks_access_entry.this]
}

variables.tf

variable "cluster_name" {
  description = "Name of the EKS cluster; also used to derive role, key alias, and tags."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]{0,99}$", var.cluster_name))
    error_message = "cluster_name must start alphanumeric and be <=100 chars of [A-Za-z0-9-]."
  }
}

variable "kubernetes_version" {
  description = "EKS Kubernetes minor version (e.g. \"1.30\"). Pin it; never track latest."
  type        = string

  validation {
    condition     = can(regex("^1\\.(2[5-9]|3[0-9])$", var.kubernetes_version))
    error_message = "kubernetes_version must be a supported 1.x minor, e.g. 1.30."
  }
}

variable "subnet_ids" {
  description = "Subnet IDs for the control-plane ENIs; span >=2 AZs, prefer private subnets."
  type        = list(string)

  validation {
    condition     = length(var.subnet_ids) >= 2
    error_message = "EKS requires subnets in at least two Availability Zones."
  }
}

variable "cluster_security_group_ids" {
  description = "Additional security groups attached to the cluster control-plane ENIs."
  type        = list(string)
  default     = []
}

variable "endpoint_private_access" {
  description = "Enable the private API server endpoint (reachable from inside the VPC)."
  type        = bool
  default     = true
}

variable "endpoint_public_access" {
  description = "Enable the public API server endpoint. Keep false, or lock down CIDRs."
  type        = bool
  default     = false
}

variable "public_access_cidrs" {
  description = "CIDRs allowed to reach the public endpoint when it is enabled."
  type        = list(string)
  default     = ["0.0.0.0/0"]

  validation {
    condition     = alltrue([for c in var.public_access_cidrs : can(cidrnetmask(c))])
    error_message = "Each public_access_cidrs entry must be a valid IPv4 CIDR."
  }
}

variable "authentication_mode" {
  description = "Cluster auth mode: API, API_AND_CONFIG_MAP, or CONFIG_MAP (legacy)."
  type        = string
  default     = "API"

  validation {
    condition     = contains(["API", "API_AND_CONFIG_MAP", "CONFIG_MAP"], var.authentication_mode)
    error_message = "authentication_mode must be API, API_AND_CONFIG_MAP, or CONFIG_MAP."
  }
}

variable "bootstrap_cluster_creator_admin_permissions" {
  description = "Grant the Terraform-running principal cluster-admin via an access entry."
  type        = bool
  default     = true
}

variable "bootstrap_self_managed_addons" {
  description = "Let EKS install default self-managed add-ons (kube-proxy, CoreDNS, VPC CNI)."
  type        = bool
  default     = true
}

variable "service_ipv4_cidr" {
  description = "Override the Service CIDR Kubernetes assigns ClusterIPs from. Null = default."
  type        = string
  default     = null
}

variable "enabled_cluster_log_types" {
  description = "Control-plane log types shipped to CloudWatch Logs."
  type        = list(string)
  default     = ["api", "audit", "authenticator"]

  validation {
    condition = alltrue([
      for t in var.enabled_cluster_log_types :
      contains(["api", "audit", "authenticator", "controllerManager", "scheduler"], t)
    ])
    error_message = "Valid log types: api, audit, authenticator, controllerManager, scheduler."
  }
}

variable "enable_irsa" {
  description = "Create the IAM OIDC provider for IRSA / web-identity pod roles."
  type        = bool
  default     = true
}

variable "create_kms_key" {
  description = "Create a customer-managed KMS key for Secrets envelope encryption."
  type        = bool
  default     = true
}

variable "kms_key_arn" {
  description = "Existing KMS key ARN for Secrets encryption (used only when create_kms_key=false)."
  type        = string
  default     = null
}

variable "kms_key_deletion_window" {
  description = "Waiting period (days) before a created KMS key is destroyed."
  type        = number
  default     = 30

  validation {
    condition     = var.kms_key_deletion_window >= 7 && var.kms_key_deletion_window <= 30
    error_message = "kms_key_deletion_window must be between 7 and 30 days."
  }
}

variable "access_entries" {
  description = "Map of API access entries and their EKS access-policy associations."
  type = map(object({
    principal_arn     = string
    kubernetes_groups = optional(list(string), [])
    type              = optional(string, "STANDARD")
    policy_associations = optional(list(object({
      policy_arn = string
      access_scope = object({
        type       = string                 # "cluster" or "namespace"
        namespaces = optional(list(string)) # required when type = "namespace"
      })
    })), [])
  }))
  default = {}
}

variable "tags" {
  description = "Tags applied to the cluster and all created IAM/KMS resources."
  type        = map(string)
  default     = {}
}

outputs.tf

output "cluster_id" {
  description = "EKS cluster ID (same as the name)."
  value       = aws_eks_cluster.this.id
}

output "cluster_name" {
  description = "Name of the EKS cluster."
  value       = aws_eks_cluster.this.name
}

output "cluster_arn" {
  description = "ARN of the EKS cluster."
  value       = aws_eks_cluster.this.arn
}

output "cluster_endpoint" {
  description = "Endpoint URL of the Kubernetes API server."
  value       = aws_eks_cluster.this.endpoint
}

output "cluster_certificate_authority_data" {
  description = "Base64 cluster CA certificate, for building a kubeconfig."
  value       = aws_eks_cluster.this.certificate_authority[0].data
}

output "cluster_security_group_id" {
  description = "ID of the EKS-managed security group created for the control plane."
  value       = aws_eks_cluster.this.vpc_config[0].cluster_security_group_id
}

output "cluster_oidc_issuer_url" {
  description = "OIDC issuer URL of the cluster (used for IRSA trust policies)."
  value       = aws_eks_cluster.this.identity[0].oidc[0].issuer
}

output "oidc_provider_arn" {
  description = "ARN of the IAM OIDC provider for IRSA, if created."
  value       = try(aws_iam_openid_connect_provider.oidc[0].arn, null)
}

output "cluster_iam_role_arn" {
  description = "ARN of the IAM role the control plane assumes."
  value       = aws_iam_role.cluster.arn
}

output "kms_key_arn" {
  description = "ARN of the KMS key used for Secrets encryption, if any."
  value       = local.kms_key_arn
}

How to use it

module "eks_cluster" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-eks?ref=v1.0.0"

  cluster_name       = "platform-prod"
  kubernetes_version = "1.30"
  subnet_ids         = module.vpc.private_subnet_ids

  # Private-only API endpoint; reach it via the VPC / a bastion or VPN.
  endpoint_private_access = true
  endpoint_public_access  = false

  # Modern auth: grant the platform admin role and a read-only SSO role.
  authentication_mode = "API"
  access_entries = {
    platform_admins = {
      principal_arn = aws_iam_role.platform_admin.arn
      policy_associations = [{
        policy_arn   = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
        access_scope = { type = "cluster" }
      }]
    }
    sre_viewers = {
      principal_arn = "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/AWSReservedSSO_SRE_ReadOnly"
      policy_associations = [{
        policy_arn   = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy"
        access_scope = { type = "namespace", namespaces = ["default", "apps"] }
      }]
    }
  }

  enable_irsa = true

  tags = {
    Environment = "production"
    Team        = "platform"
    CostCenter  = "cc-1042"
  }
}

# Downstream reference: build an IRSA role for the AWS Load Balancer Controller
# using the OIDC provider ARN and issuer URL the module produced.
data "aws_iam_policy_document" "alb_controller_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [module.eks_cluster.oidc_provider_arn]
    }

    condition {
      test     = "StringEquals"
      variable = "${replace(module.eks_cluster.cluster_oidc_issuer_url, "https://", "")}:sub"
      values   = ["system:serviceaccount:kube-system:aws-load-balancer-controller"]
    }
  }
}

resource "aws_iam_role" "alb_controller" {
  name               = "${module.eks_cluster.cluster_name}-alb-controller"
  assume_role_policy = data.aws_iam_policy_document.alb_controller_assume.json
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/eks/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-eks?ref=v1.0.0"
}

inputs = {
  cluster_name = "..."
  kubernetes_version = "..."
  subnet_ids = ["...", "..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/eks && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
cluster_name string yes Cluster name; also derives the IAM role, KMS alias, and tags.
kubernetes_version string yes EKS minor version (e.g. 1.30); pinned, validated against supported range.
subnet_ids list(string) yes Control-plane subnets spanning >=2 AZs (private preferred).
cluster_security_group_ids list(string) [] no Extra security groups attached to the control-plane ENIs.
endpoint_private_access bool true no Enable the private (in-VPC) API endpoint.
endpoint_public_access bool false no Enable the public API endpoint.
public_access_cidrs list(string) ["0.0.0.0/0"] no CIDRs allowed to the public endpoint when enabled.
authentication_mode string "API" no API, API_AND_CONFIG_MAP, or legacy CONFIG_MAP.
bootstrap_cluster_creator_admin_permissions bool true no Grant the applying principal cluster-admin via an access entry.
bootstrap_self_managed_addons bool true no Let EKS install default kube-proxy / CoreDNS / VPC CNI.
service_ipv4_cidr string null no Override the Kubernetes Service CIDR; null uses the default.
enabled_cluster_log_types list(string) ["api","audit","authenticator"] no Control-plane log types shipped to CloudWatch.
enable_irsa bool true no Create the IAM OIDC provider for IRSA pod roles.
create_kms_key bool true no Create a customer-managed KMS key for Secrets encryption.
kms_key_arn string null no Existing KMS key ARN (only when create_kms_key=false).
kms_key_deletion_window number 30 no KMS key deletion waiting period in days (7–30).
access_entries map(object) {} no API access entries and their EKS access-policy associations.
tags map(string) {} no Tags on the cluster and all created IAM/KMS resources.

Outputs

Name Description
cluster_id EKS cluster ID (same as the name).
cluster_name Name of the EKS cluster.
cluster_arn ARN of the EKS cluster.
cluster_endpoint Endpoint URL of the Kubernetes API server.
cluster_certificate_authority_data Base64 CA cert for building a kubeconfig.
cluster_security_group_id ID of the EKS-managed control-plane security group.
cluster_oidc_issuer_url OIDC issuer URL used for IRSA trust policies.
oidc_provider_arn ARN of the IAM OIDC provider for IRSA, if created.
cluster_iam_role_arn ARN of the IAM role the control plane assumes.
kms_key_arn ARN of the KMS key used for Secrets encryption, if any.

Enterprise scenario

A healthcare SaaS company runs one EKS cluster per regulated environment (dev, staging, prod) and must prove to auditors that Kubernetes Secrets are encrypted with a customer-managed key and that every control-plane API call is logged. Using this module, the platform team provisions all three clusters from the same pinned ?ref=v1.0.0 source with create_kms_key = true, enabled_cluster_log_types = ["api", "audit", "authenticator"], and endpoint_public_access = false so the API server is reachable only through the corporate VPN. Engineer access is granted purely through access_entries mapped to the AWS-managed AmazonEKSViewPolicy and AmazonEKSClusterAdminPolicy — so off-boarding someone is an IAM change, not a risky kubectl edit configmap aws-auth, and the OIDC provider lets each microservice’s pods assume a least-privilege IRSA role instead of sharing node credentials.

Best practices

TerraformAWSEKS ClusterModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading