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
- You are standing up production Kubernetes on AWS and want the control plane, its IAM role, IRSA OIDC provider, and Secrets encryption created as one audited, repeatable unit.
- You run many clusters across environments and teams (dev / staging / prod, or one per business unit) and need them to share identical security posture instead of drifting apart.
- You want IRSA / Pod Identity from day one so application pods assume least-privilege IAM roles rather than inheriting the node instance profile.
- You need to satisfy compliance controls that mandate customer-managed KMS encryption of Kubernetes Secrets and an audit trail of control-plane API calls.
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 config — live/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 config — live/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
- Keep
endpoint_public_access = falsefor production, or if you must expose it, never leavepublic_access_cidrsat0.0.0.0/0— scope it to your VPN / NAT egress ranges. A wide-open public API server is the single most common EKS finding in security reviews. - Use
authentication_mode = "API"and access entries, not theaws-authConfigMap. Access entries are declarative, IAM-auditable, and survive cluster upgrades; the ConfigMap path is a single typo away from locking everyone out of the cluster. - Encrypt Secrets with a customer-managed KMS key and enable rotation (
enable_key_rotation = trueis baked in). The default platform key does not satisfy most compliance regimes, and envelope encryption is impossible to add cleanly after the cluster exists. - Always create the IRSA OIDC provider so application pods assume scoped IAM roles via web identity. Falling back to the node instance profile gives every pod on a node the same broad permissions — a lateral-movement risk and a least-privilege failure.
- Pin
kubernetes_versionexplicitly and upgrade deliberately, one minor at a time. EKS supports a moving window of versions; tracking “latest” turns a routineterraform applyinto an unplanned control-plane upgrade with breaking API removals. - Tag consistently and ship audit/authenticator logs to CloudWatch with a retention policy so you can attribute cost per cluster and reconstruct who called the API during an incident — untagged, unlogged clusters are a recurring chargeback and forensics gap.