Cloud Engineering

Install the AWS Load Balancer Controller on EKS (v3)

Intermediate40 min to complete11 min readJuly 23, 2026Updated August 19, 2026

Quick answer

EKS ships without an ingress controller — until you install the AWS Load Balancer Controller, your Ingress manifests do nothing. Set up controller v3 with IRSA, expose a real app through an ALB, provision an NLB the modern way, and learn the teardown order that stops orphaned load balancers from billing you forever.

intermediate · 40 min

Before you begin

  • A running EKS cluster (eksctl-created is easiest — subnets come pre-tagged)
  • kubectl and Helm 3 installed and pointing at the cluster
  • AWS CLI and eksctl configured with permissions to create IAM policies and roles
  • Basic familiarity with Kubernetes Ingress and Services
AWS
EKS
Kubernetes
ALB
NLB
Ingress
Load Balancing

Here's the surprise that catches everyone new to EKS: you write an Ingress manifest, apply it, and… nothing happens. No load balancer, no address, no error. EKS doesn't ship an ingress controller — the AWS Load Balancer Controller is the missing piece that watches your Ingress and Service resources and provisions real ALBs and NLBs to match. I've written a deep dive on what the controller can do — IngressGroups, TargetGroupBinding, WAF, OIDC auth. This tutorial is the hands-on companion: from bare cluster to traffic flowing through an ALB, on the current v3 release.

If you're unsure whether your workload wants an ALB or an NLB in the first place, settle that with the ALB vs NLB guide — short version: HTTP routing wants an ALB via Ingress, raw TCP/UDP or extreme throughput wants an NLB via Service.

What You'll Build

  • AWS Load Balancer Controller v3 installed via Helm, authenticated with IRSA (no node-role credential sprawl)
  • A sample app exposed through an internet-facing ALB created from a plain Ingress manifest
  • An NLB provisioned from a LoadBalancer Service — and an understanding of v3's new default behavior there
  • The teardown order that actually deletes the load balancers (get this wrong and they outlive the cluster, billing hourly)

Step 1: Check the Prerequisites That Actually Bite

Two cluster-side requirements cause most failed installs. Check them before touching Helm.

Subnet tags. The controller discovers where to place load balancers by subnet tags: public subnets need kubernetes.io/role/elb = 1, private subnets need kubernetes.io/role/internal-elb = 1. Clusters created with eksctl are tagged automatically; hand-built VPCs usually aren't. Verify:

bash
aws ec2 describe-subnets \
  --filters "Name=tag:kubernetes.io/role/elb,Values=1" \
  --query "Subnets[].SubnetId" --output text

Empty output on a hand-rolled VPC means tagging work before anything else will function.

Webhook connectivity. The controller runs a mutating webhook on TCP 9443. Your worker-node security group must allow the control plane to reach it — eksctl defaults handle this; restrictive custom security groups are a classic silent breaker.

Step 2: IAM — OIDC Provider, Policy, Service Account

The controller calls the ELB, EC2, and ACM APIs on your behalf. IRSA (IAM Roles for Service Accounts) scopes those permissions to exactly one Kubernetes service account — the path the project's docs recommend. (EKS Pod Identity works too; IRSA remains the documented default for this controller.)

Associate the OIDC provider (idempotent — safe to run even if it exists):

bash
eksctl utils associate-iam-oidc-provider \
  --region <region> --cluster <cluster-name> --approve

Download the controller's IAM policy — version-matched to the release you're installing, not main:

bash
curl -o iam-policy.json \
  https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v3.4.2/docs/install/iam_policy.json

aws iam create-policy \
  --policy-name AWSLoadBalancerControllerIAMPolicy \
  --policy-document file://iam-policy.json

Create the service account bound to that policy:

bash
1eksctl create iamserviceaccount \
2  --cluster=<cluster-name> \
3  --namespace=kube-system \
4  --name=aws-load-balancer-controller \
5  --attach-policy-arn=arn:aws:iam::<ACCOUNT_ID>:policy/AWSLoadBalancerControllerIAMPolicy \
6  --override-existing-serviceaccounts \
7  --region <region> --approve

Step 3: Install the Controller with Helm

bash
1helm repo add eks https://aws.github.io/eks-charts
2helm repo update
3
4helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
5  -n kube-system \
6  --set clusterName=<cluster-name> \
7  --set serviceAccount.create=false \
8  --set serviceAccount.name=aws-load-balancer-controller

serviceAccount.create=false is the line people miss: it tells the chart to use the IRSA-annotated account from Step 2 instead of creating a credential-less one.

Verify:

bash
kubectl get deployment -n kube-system aws-load-balancer-controller
# READY 2/2

kubectl logs -n kube-system deploy/aws-load-balancer-controller | head

Two replicas with leader election is the default — one active, one standby.

Step 4: Expose an App Through an ALB

Deploy something to route to:

bash
kubectl create deployment echo --image=ealen/echo-server:latest --replicas=2
kubectl expose deployment echo --port=80

Now the part that previously did nothing — an Ingress:

yaml
1# ingress.yaml
2apiVersion: networking.k8s.io/v1
3kind: Ingress
4metadata:
5  name: echo
6  annotations:
7    alb.ingress.kubernetes.io/scheme: internet-facing
8    alb.ingress.kubernetes.io/target-type: ip
9spec:
10  ingressClassName: alb
11  rules:
12    - http:
13        paths:
14          - path: /
15            pathType: Prefix
16            backend:
17              service:
18                name: echo
19                port:
20                  number: 80

Two annotations carry the weight: scheme: internet-facing (default is internal — a private ALB you can't reach from your laptop), and target-type: ip, which registers pod IPs directly with the target group instead of bouncing through NodePorts. On VPC CNI clusters, ip mode is the one you want — fewer hops, correct health checks, works with Fargate.

bash
kubectl apply -f ingress.yaml
kubectl get ingress echo -w

The ADDRESS column populates with an ALB DNS name in ~2–3 minutes (AWS provisioning time, not the controller being slow):

bash
curl http://$(kubectl get ingress echo -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')

JSON from the echo server means the full chain works: Ingress → controller → ALB → target group → pod IPs. For TLS, add an ACM certificate ARN via alb.ingress.kubernetes.io/certificate-arn — or if you run cert-manager, the Ingress TLS tutorial covers that path.

Step 5: NLBs — and v3's New Default

For TCP/UDP workloads, the controller provisions NLBs from plain Services:

yaml
1apiVersion: v1
2kind: Service
3metadata:
4  name: echo-nlb
5  annotations:
6    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
7    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
8spec:
9  type: LoadBalancer
10  selector:
11    app: echo
12  ports:
13    - port: 80

The v3 behavior change worth knowing: since v3, the controller's service webhook makes it the default handler for every type: LoadBalancer Service in the cluster — no annotation opt-in needed. That's usually what you want (NLBs instead of legacy Classic Load Balancers), but it means installing the controller changes what happens when anyone on the cluster creates a LoadBalancer Service. If you need the old opt-in behavior, install with --set enableServiceMutatorWebhook=false.

Common Issues

  • Ingress sits with no ADDRESS, controller logs say "couldn't auto-discover subnets." Your subnets lack the role tags from Step 1. Tag them (kubernetes.io/role/elb=1 public, internal-elb private) — the controller retries automatically.
  • AccessDenied errors in the controller logs. Almost always a policy-version mismatch: the IAM policy JSON must come from the same release tag as the controller (new versions add API calls). Re-download for v3.4.2 and update the policy rather than assuming an old policy still fits.
  • Webhook timeouts on kubectl apply (failed calling webhook). The control plane can't reach TCP 9443 on your workers — fix the node security group. This also blocks unrelated Service creations once the v3 service webhook is active, which is how it usually gets noticed.
  • ALB created but targets unhealthy. With target-type: ip, the health check hits pod IPs directly — your pod's security group (or the node SG in most CNI setups) must allow traffic from the ALB's security group on the traffic port, and your app must answer / or the configured health-check path with a 200.
  • Load balancer survives kubectl delete / cluster deletion. The controller deletes an ALB only while it's alive to process the Ingress deletion. Delete Ingresses and LoadBalancer Services before uninstalling the controller or destroying the cluster — see Tear Down. If you're already stuck, delete the ALB/NLB and its target groups manually in the console.

Frequently Asked Questions

Do I need this if I already run ingress-nginx?

They solve different layers. ingress-nginx does routing inside the cluster but still needs something to provision the load balancer in front of it — commonly an NLB, which on EKS is exactly this controller's job. Many production clusters run both: AWS LBC for the NLB/ALB layer, ingress-nginx for in-cluster routing. Running ALB-per-Ingress alone is simpler when your routing needs are modest.

One ALB per Ingress sounds expensive. Is it?

By default, yes — every Ingress gets its own ALB (~$16/month + LCU charges). The fix is alb.ingress.kubernetes.io/group.name: Ingresses sharing a group name share one ALB with merged rules. The deep-dive post covers IngressGroup patterns and their gotchas.

IRSA or EKS Pod Identity?

Both work. This tutorial uses IRSA because it's the path the controller's own documentation recommends and it works on every EKS version. Pod Identity is AWS's newer, simpler-to-operate mechanism — if your platform has standardized on it, use it; the controller doesn't care where its credentials come from.

Does the controller support Gateway API?

Yes — Gateway API support went GA in v3.0. It needs the standard Gateway API CRDs plus the controller's gateway-specific CRDs installed separately, and then ALBs/NLBs are managed through Gateway/HTTPRoute resources instead of Ingress. If you're weighing that migration, start with Ingress vs Gateway API.

Can I point an ALB at pods without any Ingress resource?

Yes — TargetGroupBinding attaches an existing target group (say, one Terraform created) directly to a Service. It's the escape hatch for brownfield setups where the load balancer is managed outside Kubernetes; the companion post shows the manifest.

Tear Down

Order matters — the controller must be alive to reap what it created:

bash
1kubectl delete ingress echo
2kubectl delete service echo-nlb
3# wait until the ALB/NLB disappear from the AWS console (~1 min)
4kubectl delete service echo && kubectl delete deployment echo
5
6helm uninstall aws-load-balancer-controller -n kube-system
7eksctl delete iamserviceaccount --cluster=<cluster-name> \
8  --namespace=kube-system --name=aws-load-balancer-controller
9aws iam delete-policy --policy-arn arn:aws:iam::<ACCOUNT_ID>:policy/AWSLoadBalancerControllerIAMPolicy

Official References

Next steps: the controller deep dive for IngressGroups, WAF, and OIDC auth; ALB vs NLB if you're still choosing; and when an ALB starts throwing 502s, the fix guide has the diagnosis tree.

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.