Skip to content

EKS with Karpenter on AWS Fargate (Pure Karpenter NodePools)

karpenter

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 NodePool consolidation, 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:

  1. vpc-cni is deployed with before_compute = true and configured with ENIConfig custom networking so secondary CIDR pod subnets are ready before any node joins.
  2. Karpenter CRDs & Controller deploy onto the karpenter Fargate profile.
  3. Default NodePool & EC2NodeClass are applied via Helm.
  4. 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