EKS with Karpenter on AWS Fargate (Pure Karpenter NodePools)

In a traditional Amazon EKS setup with Karpenter, a chicken-and-egg problem exists at cluster creation: Karpenter is a Kubernetes controller that runs as a pod, which means it needs compute capacity before it can provision worker nodes. Historically, platform teams solved this by maintaining a small, dedicated EKS Managed Node Group just to host the Karpenter controller and CoreDNS.
By running the Karpenter controller on an AWS Fargate Profile, we eliminate the need for any managed node groups at bootstrap (nodeless bootstrap). Fargate runs only the karpenter namespace, while 100% of EC2 worker nodes—including those running CoreDNS, the AWS Load Balancer Controller, ArgoCD, and application workloads—are dynamically provisioned and consolidated by Karpenter NodePools.
📁 Source Code: eks-cluster-platform / clusters / eks-demo
Architecture & Bootstrap Flow
graph TD
subgraph ControlPlane ["EKS Control Plane & Serverless Bootstrap"]
API["EKS API Server"]
FP["AWS Fargate Profile<br/>(namespace: karpenter)"]
KC["Karpenter Controller Pod<br/>(IRSA + dnsPolicy: Default)"]
FP --> KC
end
subgraph KarpenterCRDs ["Karpenter v1 Constructs"]
NP["NodePool (default)<br/>Spot/On-Demand, amd64/arm64"]
NC["EC2NodeClass (default)<br/>Bottlerocket AMI, Discovery Tags"]
NP -->|nodeClassRef| NC
end
subgraph WorkerNodes ["Karpenter-Provisioned EC2 Nodes"]
EC2["Bottlerocket EC2 Instances<br/>(Primary ENI: Private Subnet / Secondary ENI: Pod Subnet)"]
SysPods["System & Platform Pods<br/>(CoreDNS, AWS LBC, ArgoCD)"]
AppPods["Application Workloads"]
EC2 --> SysPods
EC2 --> AppPods
end
KC -->|Watches Pending Pods| API
KC -->|Reconciles| NP
KC -->|Provisions via EC2 Fleet API| EC2
Why Run Karpenter on Fargate?
- Zero Static Node Groups: No baseline EC2 Managed Node Groups to patch, upgrade, or pay for when idle.
- 100% Karpenter-Managed EC2 Fleet: Every EC2 instance in the cluster is governed by Karpenter
NodePoolconsolidation, Spot fallback, multi-arch (amd64/arm64) selection, and automated drift rotation. - Isolated Control Loop: Because the Karpenter controller runs on Fargate, it cannot accidentally terminate or disrupt the node it is running on during aggressive consolidation events.
Key Design Considerations for Karpenter on Fargate
Running Karpenter on AWS Fargate requires solving three specific architectural nuances during cluster bootstrap:
1. DNS Resolution Before CoreDNS (dnsPolicy: Default)
In this architecture, CoreDNS runs on Karpenter-provisioned EC2 nodes, not on Fargate. When the Karpenter controller starts on Fargate, CoreDNS is still in a Pending state waiting for a node.
If Karpenter uses the default Kubernetes ClusterFirst DNS policy, it will attempt to query CoreDNS to resolve AWS API endpoints (sts, ec2, sqs) and fail to boot. Setting dnsPolicy = "Default" on the Karpenter Helm chart instructs the Fargate pod to fall back directly to the underlying Amazon VPC DNS resolver, allowing Karpenter to reach AWS APIs and provision the first node for CoreDNS.
2. IAM Authentication (IRSA vs. EKS Pod Identity)
AWS Fargate does not support the EKS Pod Identity Agent (eks-pod-identity-agent runs as a DaemonSet on EC2 nodes). Therefore, the Karpenter controller on Fargate must authenticate using IAM Roles for Service Accounts (IRSA) via the cluster's OIDC provider (create_pod_identity_association = false), while controllers running on Karpenter EC2 nodes (such as the AWS Load Balancer Controller) can use standard EKS Pod Identity.
3. Ordered Add-on Sequencing
To ensure a clean single-apply bootstrap:
vpc-cniis deployed withbefore_compute = trueand configured withENIConfigcustom networking so secondary CIDR pod subnets are ready before any node joins.- Karpenter CRDs & Controller deploy onto the
karpenterFargate profile. - Default
NodePool&EC2NodeClassare applied via Helm. - CoreDNS, AWS Load Balancer Controller, and ArgoCD explicitly depend on
helm_release.karpenter_nodepool, immediately triggering Karpenter to launch the first Bottlerocket EC2 node.
Step-by-Step Terraform Implementation
Step 1. Cluster Invoker Configuration
At the cluster definition layer (clusters/eks-demo/eks-demo-cluster.tf), the reusable eks/v1.1 module is invoked with VPC subnet discovery filters and add-on versions:
eks-demo-cluster.tf
locals {
build_trigger = "1.0.1"
kubernetes_version = "1.36"
cluster_name = "eks-demo"
vpc_name = "eks-demo-vpc"
enable_cluster_creator_admin_permissions = true
endpoint_public_access_cidrs = ["0.0.0.0/0"]
karpenter_version = "1.9.2"
loadbalancer_controller_version = "3.6.0"
enable_thanos = false
enable_argocd = true
}
module "eks" {
source = "../../modules/eks/v1.1"
build_trigger = local.build_trigger
cluster_name = local.cluster_name
kubernetes_version = local.kubernetes_version
vpc_name = local.vpc_name
private_subnet_tags = ["${local.vpc_name}-private-*"]
pod_subnet_tags = ["${local.vpc_name}-podsubnet-*"]
enable_cluster_creator_admin_permissions = local.enable_cluster_creator_admin_permissions
enable_endpoint_public_access = true
endpoint_public_access_cidrs = local.endpoint_public_access_cidrs
karpenter_version = local.karpenter_version
loadbalancer_controller_version = local.loadbalancer_controller_version
enable_argocd = local.enable_argocd
enable_thanos = local.enable_thanos
}
Step 2. EKS Cluster & Fargate Profile (eks.tf)
Inside the module (modules/eks/v1.1/eks.tf), the EKS cluster is provisioned with no managed node groups, a Fargate profile scoped exclusively to the karpenter namespace, and vpc-cni configured for secondary CIDR custom networking (ENIConfig per Availability Zone).
We also tag the module-created node security group with karpenter.sh/discovery = var.cluster_name so Karpenter-provisioned nodes share the same security group rules.
eks.tf
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 21.21"
name = var.cluster_name
kubernetes_version = var.kubernetes_version
vpc_id = data.aws_vpc.this.id
subnet_ids = data.aws_subnets.private.ids
authentication_mode = "API"
enable_cluster_creator_admin_permissions = var.enable_cluster_creator_admin_permissions
endpoint_public_access = var.enable_endpoint_public_access
endpoint_public_access_cidrs = var.endpoint_public_access_cidrs
compute_config = {
enabled = false
}
addons = {
vpc-cni = {
before_compute = true
most_recent = true
addon_version = coalesce(var.vpc_cni_version, data.aws_eks_addon_version.vpc_cni.version)
configuration_values = jsonencode({
env = {
AWS_VPC_K8S_CNI_CUSTOM_NETWORK_CFG = "true"
ENABLE_POD_ENI = "true"
ENABLE_PREFIX_DELEGATION = "true"
ENABLE_SUBNET_DISCOVERY = "true"
ENI_CONFIG_LABEL_DEF = "topology.kubernetes.io/zone"
}
eniConfig = {
create = var.create_eniconfig
region = data.aws_region.current.region
subnets = {
for id, subnet in data.aws_subnet.pod_subnet : subnet.availability_zone => {
id = id
securityGroups = [module.eks.node_security_group_id]
}
}
}
})
}
}
# Fargate profile for Karpenter controller
# Only Karpenter controller runs on Fargate; all other workloads run on Karpenter-provisioned nodes
fargate_profiles = {
karpenter = {
selectors = [
{
namespace = "karpenter"
}
]
subnet_ids = data.aws_subnets.private.ids
}
}
node_security_group_tags = {
"karpenter.sh/discovery" = var.cluster_name
"karpenter.sh/discovery/${var.cluster_name}" = "true"
}
}
Step 3. Karpenter IAM (IRSA), Controller & NodePool (iam-karpenter.tf)
The terraform-aws-modules/eks/aws//modules/karpenter submodule creates the controller IAM role, node IAM role, EKS Access Entry for nodes, and SQS/EventBridge rules for native Spot interruption handling.
Because the controller runs on Fargate, create_pod_identity_association is set to false and an explicit OIDC IRSA trust policy is attached.
iam-karpenter.tf
# ECR Public authorization token (ECR Public API is only available in us-east-1)
data "aws_ecrpublic_authorization_token" "token" {
region = "us-east-1"
}
# IRSA trust policy for Karpenter controller running on AWS Fargate
data "aws_iam_policy_document" "karpenter_controller_irsa_assume_role" {
statement {
sid = "KarpenterControllerIRSA"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [module.eks.oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "${module.eks.oidc_provider}:sub"
values = ["system:serviceaccount:karpenter:karpenter"]
}
condition {
test = "StringEquals"
variable = "${module.eks.oidc_provider}:aud"
values = ["sts.amazonaws.com"]
}
}
}
module "karpenter" {
source = "terraform-aws-modules/eks/aws//modules/karpenter"
version = "~> 21.21"
cluster_name = module.eks.cluster_name
# Disable EKS Pod Identity (not supported on Fargate) and use IRSA trust policy instead
create_pod_identity_association = false
iam_role_override_assume_policy_documents = [
data.aws_iam_policy_document.karpenter_controller_irsa_assume_role.json
]
namespace = "karpenter"
service_account = "karpenter"
enable_spot_termination = true
node_iam_role_additional_policies = {
AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
}
# 1. Deploy Karpenter CRDs via Helm
resource "helm_release" "karpenter_crd" {
name = "karpenter-crd"
namespace = "karpenter"
create_namespace = true
repository = "oci://public.ecr.aws/karpenter"
repository_username = data.aws_ecrpublic_authorization_token.token.user_name
repository_password = data.aws_ecrpublic_authorization_token.token.password
chart = "karpenter-crd"
version = var.karpenter_version
depends_on = [
module.eks.fargate_profiles
]
}
# 2. Deploy Karpenter Controller onto Fargate
resource "helm_release" "karpenter" {
name = "karpenter"
namespace = "karpenter"
create_namespace = true
repository = "oci://public.ecr.aws/karpenter"
repository_username = data.aws_ecrpublic_authorization_token.token.user_name
repository_password = data.aws_ecrpublic_authorization_token.token.password
chart = "karpenter"
version = var.karpenter_version
values = [
yamlencode({
replicas = 1
serviceAccount = {
name = "karpenter"
annotations = {
"eks.amazonaws.com/role-arn" = module.karpenter.iam_role_arn
}
}
settings = {
clusterName = module.eks.cluster_name
clusterEndpoint = module.eks.cluster_endpoint
interruptionQueue = module.karpenter.queue_name
}
controller = {
resources = {
requests = {
cpu = "300m"
memory = "512Mi"
}
limits = {
cpu = "300m"
memory = "512Mi"
}
}
}
nodeSelector = {
"kubernetes.io/os" = "linux"
}
# CRITICAL FOR FARGATE:
# Resolves AWS APIs using VPC DNS directly before CoreDNS is running
dnsPolicy = "Default"
})
]
depends_on = [
helm_release.karpenter_crd,
module.eks.fargate_profiles,
module.karpenter,
]
}
# 3. Deploy Default Karpenter NodePool and EC2NodeClass
resource "helm_release" "karpenter_nodepool" {
name = "karpenter-nodepool"
chart = "${path.module}/helm/nodepool"
namespace = "karpenter"
values = [
yamlencode({
clusterName = module.eks.cluster_name
nodeRole = module.karpenter.node_iam_role_name
})
]
depends_on = [
helm_release.karpenter
]
}
Step 4. Default NodePool and Bottlerocket EC2NodeClass
The local ./helm/nodepool chart deploys the default NodePool and EC2NodeClass immediately after the Karpenter controller is ready. It supports multi-architecture (arm64 and amd64), Spot and On-Demand capacity, and Bottlerocket OS:
helm/nodepool/templates/nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["arm64", "amd64"]
- key: kubernetes.io/os
operator: In
values: ["linux"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m", "r"]
- key: karpenter.k8s.aws/instance-generation
operator: Gte
values: ["5"]
- key: karpenter.k8s.aws/instance-cpu
operator: Gte
values: ["2"]
- key: karpenter.k8s.aws/instance-memory
operator: Gt
values: ["2048"]
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
limits:
cpu: 50
memory: 128Gi
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 1m
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: default
spec:
amiFamily: Bottlerocket
amiSelectorTerms:
- alias: bottlerocket@latest
role: {{ .Values.nodeRole | quote }}
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: {{ .Values.clusterName | quote }}
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: {{ .Values.clusterName | quote }}
tags:
karpenter.sh/discovery: {{ .Values.clusterName | quote }}
Name: {{ printf "%s-karpenter-node" .Values.clusterName | quote }}
Step 5. Core Add-ons Scheduled onto Karpenter Nodes (eks-addons.tf)
Because CoreDNS does not match the karpenter Fargate profile selector, it remains pending until Karpenter provisions an EC2 worker node. Adding depends_on = [helm_release.karpenter_nodepool] ensures Karpenter is active and ready to satisfy the pending CoreDNS pod immediately:
eks-addons.tf
resource "aws_eks_addon" "eks-pod-identity-agent" {
cluster_name = module.eks.cluster_name
addon_name = "eks-pod-identity-agent"
addon_version = coalesce(var.eks_pod_identity_agent_addon_version, data.aws_eks_addon_version.eks_pod_identity_agent.version)
depends_on = [module.eks]
}
resource "aws_eks_addon" "kube-proxy" {
cluster_name = module.eks.cluster_name
addon_name = "kube-proxy"
addon_version = coalesce(var.kube_proxy_version, data.aws_eks_addon_version.kube_proxy.version)
depends_on = [module.eks]
}
resource "aws_eks_addon" "coredns" {
cluster_name = module.eks.cluster_name
addon_name = "coredns"
addon_version = coalesce(var.coredns_version, data.aws_eks_addon_version.coredns.version)
configuration_values = jsonencode({
replicaCount = 1
})
# CoreDNS runs on worker nodes provisioned by Karpenter (not on Fargate)
depends_on = [
module.eks,
helm_release.karpenter,
helm_release.karpenter_nodepool,
]
}
Deployment & Verification
Because the Terraform helm and kubernetes providers depend on the EKS cluster endpoint and CA certificate, deploy the cluster using a two-stage apply (run.sh):
#!/bin/bash
terraform init -upgrade
# Stage 1: Provision the EKS cluster first so cluster_endpoint and CA data are known
terraform apply -target=module.eks --auto-approve
# Stage 2: Provision Karpenter on Fargate, NodePools, CoreDNS, LBC, and ArgoCD
terraform apply --auto-approve
# Configure local kubeconfig
aws eks --profile labs --region eu-west-1 update-kubeconfig --name eks-demo --alias eks-demo
Verify Fargate vs. Karpenter Node Placement
# 1. Inspect nodes — observe the Fargate serverless node alongside Karpenter EC2 nodes
kubectl get nodes -o wide
# 2. Verify Karpenter controller is running on the Fargate node
kubectl get pods -n karpenter -o wide
# 3. Verify CoreDNS and AWS Load Balancer Controller are running on Karpenter EC2 nodes
kubectl get pods -n kube-system -o wide
# 4. Inspect Karpenter NodePool and active NodeClaims
kubectl get nodepool,ec2nodeclass,nodeclaims