Kubernetes in AWS using HashiCorp Terraform: IAM and EKS (Part 2)

This is part 2 of a series on provisioning a Kubernetes cluster in AWS using HashiCorp Terraform. Part 1 covered the VPC — the isolated virtual network that hosts the cluster. This part covers identity: defining the IAM roles and policies that allow EKS to manage AWS resources on your behalf, then using those roles to provision the control plane and a managed node group.

The guide assumes familiarity with the VPC setup from part 1 and a basic understanding of Kubernetes concepts (control plane, worker nodes, pods).

Define IAM roles

Control plane and data plane IAM roles need to be defined. One can imagine an IAM role as an entry card with a name on it.
Principal is allowed to perform a defined action — in this case, assume the role. Assuming an empty role does not have much meaning — like the entry card, until an IT administrator defines which doors can be opened with it, it is just an antenna inside a piece of plastic.

# Tells: 'eks.amazonaws.com' can assume eks-cluster-role
# Needed for control plane
resource "aws_iam_role" "eks-cluster-role" {
    assume_role_policy = jsonencode(
        {
            Version = "2012-10-17",
            Statement = [{
                Effect = "Allow"
                Action = "sts:AssumeRole" # What
                Principal = {Service = "eks.amazonaws.com"} # Who
            }]
        })
}

# Needed for data plane
resource "aws_iam_role" "eks-worker-role" {
    assume_role_policy = jsonencode(
        {
            Version = "2012-10-17",
            Statement = [{
                Effect = "Allow"
                Action = "sts:AssumeRole" # What
                Principal = {Service = "ec2.amazonaws.com"} # Who
            }]
        })
}

Visualization of generated resources:

graph LR
    EKSSvc["eks.amazonaws.com"]
    EC2Svc["ec2.amazonaws.com"]
    CPRole["eks-cluster-role"]
    WKRole["eks-worker-role"]

    EKSSvc -->|"AssumeRole"| CPRole
    EC2Svc -->|"AssumeRole"| WKRole

style EKSSvc fill:#FFF3E0,stroke:#FF9900,stroke-width:2px
style EC2Svc fill:#FFF3E0,stroke:#FF9900,stroke-width:2px
style CPRole fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px
style WKRole fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px

Attach a policy for control plane

One must remember that the EKS control plane is not a resource that runs in an administrator-defined VPC — it runs on AWS’s own infrastructure and needs to be given permissions to manage resources. AmazonEKSClusterPolicy gives the control plane permission to do things like:

  • Create network interfaces in your VPC — the control plane needs to attach an ENI (Elastic Network Interface) into your subnets so it can communicate with your worker nodes. Without this, it can’t talk to the nodes at all.
  • Describe EC2 resources — it needs to understand your VPC topology, subnets, and routing to make intelligent scheduling decisions.
  • Write CloudWatch logs — for control plane audit logging.
# Tells: eks-cluster-role can use AmazonEKSClusterPolicy
# AmazonEKSClusterPolicy -  provides Kubernetes the permissions it requires to manage resources on your behalf
resource "aws_iam_role_policy_attachment" "eks-control-policy" {
  role = aws_iam_role.eks-cluster-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}

Visualization of generated resources:

graph LR
    EKSSvc["eks.amazonaws.com"]
    EC2Svc["ec2.amazonaws.com"]
    CPRole["eks-cluster-role"]
    WKRole["eks-worker-role"]
    CPPolicy["AmazonEKSClusterPolicy"]

    EKSSvc -->|"AssumeRole"| CPRole
    EC2Svc -->|"AssumeRole"| WKRole
    CPRole -->|"attached"| CPPolicy

style EKSSvc fill:#FFF3E0,stroke:#FF9900,stroke-width:2px
style EC2Svc fill:#FFF3E0,stroke:#FF9900,stroke-width:2px
style CPRole fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px
style WKRole fill:#A9A9A9,stroke:#9E9E9E,stroke-width:1px
style CPPolicy fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px

Attach a policy for worker nodes

Next, the data plane policies can be defined and attached to the eks-worker-role:

  • AmazonEKSWorkerNodePolicy — lets the node register itself with the control plane and describe EC2/EKS resources around it. Without it, the node boots up but the cluster never knows it exists.
  • AmazonEKS_CNI_Policy — lets the CNI plugin allocate real VPC IP addresses for pods by creating/attaching network interfaces on the node. Without it, pods start with no IP and can’t communicate.
  • AmazonEC2ContainerRegistryReadOnly — lets the node pull container images from ECR. Needed even if your app isn’t on ECR, because EKS system components (CoreDNS, kube-proxy) pull their images from ECR.
resource "aws_iam_role_policy_attachment" "eks-worker-node-policy" {
  role = aws_iam_role.eks-worker-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}
resource "aws_iam_role_policy_attachment" "eks-worker-cni-policy" {
  role = aws_iam_role.eks-worker-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}
# Even if your own application never touches ECR, the node still needs to pull system images from ECR
resource "aws_iam_role_policy_attachment" "eks-worker-registry-policy" {
  role = aws_iam_role.eks-worker-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}

Visualization of generated resources:

graph LR
    EKSSvc["eks.amazonaws.com"]
    EC2Svc["ec2.amazonaws.com"]
    CPRole["eks-cluster-role"]
    WKRole["eks-worker-role"]
    CPPolicy["AmazonEKSClusterPolicy"]
    WNPolicy["AmazonEKSWorkerNodePolicy"]
    CNIPolicy["AmazonEKS_CNI_Policy"]
    RegPolicy["AmazonEC2ContainerRegistryReadOnly"]

    EKSSvc -->|"AssumeRole"| CPRole
    EC2Svc -->|"AssumeRole"| WKRole
    CPRole -->|"attached"| CPPolicy
    WKRole -->|"attached"| WNPolicy
    WKRole -->|"attached"| CNIPolicy
    WKRole -->|"attached"| RegPolicy

style EKSSvc fill:#FFF3E0,stroke:#FF9900,stroke-width:2px
style EC2Svc fill:#FFF3E0,stroke:#FF9900,stroke-width:2px
style CPRole fill:#A9A9A9,stroke:#9E9E9E,stroke-width:1px
style CPPolicy fill:#A9A9A9,stroke:#9E9E9E,stroke-width:1px
style WKRole fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px
style WNPolicy fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px
style CNIPolicy fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px
style RegPolicy fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px

Create EKS control plane

The control plane is an AWS-managed API endpoint. It must reference subnets from both the private and public ranges — private subnets for worker nodes, public subnets for future load balancers. The cluster must also be given the cluster role via role_arn.

# Create control plane
resource "aws_eks_cluster" "cluster-eu-1" {
  name = "cluster-eu-1"
  role_arn = aws_iam_role.eks-cluster-role.arn
  vpc_config { # Nested block that tells EKS which subnets to use
    subnet_ids = [ 
      aws_subnet.kubernetes-private-1a.id, 
      aws_subnet.kubernetes-private-1b.id,
      aws_subnet.kubernetes-public-1a.id,
      aws_subnet.kubernetes-public-1b.id
      ]
    }
}

Create EKS data plane

Define the data plane. Administrators can choose whether they want to run EC2 nodes or use AWS Fargate to run their workloads.

  • EC2 (Managed Node Groups) — used in this example. The administrator chooses the instance type, and AWS manages the node lifecycle (launching, draining, and replacing nodes during upgrades). The OS is visible to the instance owner.
  • Fargate — serverless. The administrator defines a pod, and AWS runs it on infrastructure the cluster owner never sees or manages. No nodes, no EC2, no SSH. The tradeoff is losing some control — no DaemonSets, no GPU, no privileged containers.

The node_role_arn attribute wires the node group to the data plane IAM role defined earlier.

# Data plane
resource "aws_eks_node_group" "cluster-eu-1" {
  cluster_name = aws_eks_cluster.cluster-eu-1.name
  node_role_arn = aws_iam_role.eks-worker-role.arn
  subnet_ids = [aws_subnet.kubernetes-private-1a.id,
  aws_subnet.kubernetes-private-1b.id]
  scaling_config {
    desired_size = 2
    min_size = 2
    max_size = 2
  }
  # Use `t3.small` for free tier
  instance_types = ["t3.medium"]
}

Visualization of generated resources:

graph TB
    subgraph AWSManaged["AWS Managed Infrastructure"]
        CP["EKS Control Plane<br/>cluster-eu-1"]
    end

    subgraph AWS["AWS eu-central-1"]
        subgraph VPC["VPC (10.0.0.0/16)"]
            subgraph AZ1a["Availability Zone 1a"]
                PubSub1a["🌐 Public Subnet<br/>10.0.101.0/24"]
                subgraph PrivSub1a["🔒 Private Subnet 10.0.201.0/24"]
                    Node1["Worker Node<br/>t3.medium"]
                end
            end
            subgraph AZ1b["Availability Zone 1b"]
                PubSub1b["🌐 Public Subnet<br/>10.0.102.0/24"]
                subgraph PrivSub1b["🔒 Private Subnet 10.0.202.0/24"]
                    Node2["Worker Node<br/>t3.medium"]
                end
            end
        end
    end

    CP -->|"Elastic Network Interface"| Node1
    CP -->|"Elastic Network Interface"| Node2

style AWSManaged fill:#E8F5E9,stroke:#388E3C,stroke-width:2px
style CP fill:#A9A9A9,stroke:#9E9E9E,stroke-width:1px
style AWS fill:#FFF3E0,stroke:#FF9900,stroke-width:2px
style VPC fill:#F3E5F5,stroke:#7B1FA2,stroke-width:2px
style AZ1a fill:#F5F5F5,stroke:#9E9E9E,stroke-width:1px
style AZ1b fill:#F5F5F5,stroke:#9E9E9E,stroke-width:1px
style PubSub1a fill:#A9A9A9,stroke:#9E9E9E,stroke-width:1px
style PubSub1b fill:#A9A9A9,stroke:#9E9E9E,stroke-width:1px
style PrivSub1a fill:#A9A9A9,stroke:#9E9E9E,stroke-width:1px
style PrivSub1b fill:#A9A9A9,stroke:#9E9E9E,stroke-width:1px
style Node1 fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px
style Node2 fill:#B6D0E2,stroke:#9E9E9E,stroke-width:1px

image

Complete HCL file

# Tells: 'eks.amazonaws.com' can assume eks-cluster-role
# Needed for control plane
resource "aws_iam_role" "eks-cluster-role" {
    assume_role_policy = jsonencode(
        {
            Version = "2012-10-17",
            Statement = [{
                Effect = "Allow"
                Action = "sts:AssumeRole" # What
                Principal = {Service = "eks.amazonaws.com"} # Who
            }]
        })
}

# Needed for data plane
resource "aws_iam_role" "eks-worker-role" {
    assume_role_policy = jsonencode(
        {
            Version = "2012-10-17",
            Statement = [{
                Effect = "Allow"
                Action = "sts:AssumeRole" # What
                Principal = {Service = "ec2.amazonaws.com"} # Who
            }]
        })
}

# Tells: eks-cluster-role can use AmazonEKSClusterPolicy
# AmazonEKSClusterPolicy: 'This policy provides Kubernetes the permissions it requires to manage resources on your behalf'
resource "aws_iam_role_policy_attachment" "eks-control-policy" {
  role = aws_iam_role.eks-cluster-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}

# Data plane policies
resource "aws_iam_role_policy_attachment" "eks-worker-node-policy" {
  role = aws_iam_role.eks-worker-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}
resource "aws_iam_role_policy_attachment" "eks-worker-cni-policy" {
  role = aws_iam_role.eks-worker-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}
# Even if your own application never touches ECR, the node still needs to pull system images from ECR
resource "aws_iam_role_policy_attachment" "eks-worker-registry-policy" {
  role = aws_iam_role.eks-worker-role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}

# Create control plane
resource "aws_eks_cluster" "cluster-eu-1" {
  name = "cluster-eu-1"
  role_arn = aws_iam_role.eks-cluster-role.arn
  vpc_config { # Nested block that tells EKS which subnets to use
    subnet_ids = [ 
      aws_subnet.kubernetes-private-1a.id, 
      aws_subnet.kubernetes-private-1b.id,
      aws_subnet.kubernetes-public-1a.id,
      aws_subnet.kubernetes-public-1b.id
      ]
    }
}

# Data plane
resource "aws_eks_node_group" "cluster-eu-1" {
  cluster_name = aws_eks_cluster.cluster-eu-1.name
  node_role_arn = aws_iam_role.eks-worker-role.arn
  subnet_ids = [aws_subnet.kubernetes-private-1a.id,
  aws_subnet.kubernetes-private-1b.id]
  scaling_config {
    desired_size = 2
    min_size = 2
    max_size = 2
  }
  instance_types = ["t3.medium"]
}

Run a sample application

Use the AWS CLI on your local machine to import the ~/.kube/config

aws eks update-kubeconfig --region eu-central-1 --name cluster-eu-1

Test connection by running: kubectl get nodes

[user@machine ~]# kubectl get nodes 
NAME                                           STATUS   ROLES    AGE   VERSION
ip-10-0-201-15.eu-central-1.compute.internal   Ready    <none>   16m   v1.35.5-eks-3385e9b
ip-10-0-202-8.eu-central-1.compute.internal    Ready    <none>   16m   v1.35.5-eks-3385e9b

Create a sample NGINX deployment:

kubectl create deployment nginx --image=nginx

Port-forward to localhost and test:

kubectl port-forward deployment/nginx 8080:80
[user@machine ~]# curl localhost:8080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, nginx is successfully installed and working.
Further configuration is required for the web server, reverse proxy, 
API gateway, load balancer, content cache, or other features.</p>

<p>For online documentation and support please refer to
<a href="https://nginx.org/">nginx.org</a>.<br/>
To engage with the community please visit
<a href="https://community.nginx.org/">community.nginx.org</a>.<br/>
For enterprise grade support, professional services, additional 
security features and capabilities please refer to
<a href="https://f5.com/nginx">f5.com/nginx</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

terraform apply provisions the full stack: two IAM roles with their attached policies, the managed EKS control plane, and two t3.medium worker nodes in the private subnets — confirmed working with the nginx response above. Do not forget to run terraform destroy when you are done testing for the day.

Sources