Install the AWS Load Balancer Controller on EKS (v3)
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.
- Step 1: Check the Prerequisites That Actually Bite
- Step 2: IAM — OIDC Provider, Policy, Service Account
- Step 3: Install the Controller with Helm
- Step 4: Expose an App Through an ALB
- Step 5: NLBs — and v3's New Default
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
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
LoadBalancerService — 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:
aws ec2 describe-subnets \
--filters "Name=tag:kubernetes.io/role/elb,Values=1" \
--query "Subnets[].SubnetId" --output textEmpty 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):
eksctl utils associate-iam-oidc-provider \
--region <region> --cluster <cluster-name> --approveDownload the controller's IAM policy — version-matched to the release you're installing, not main:
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.jsonCreate the service account bound to that policy:
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> --approveStep 3: Install the Controller with Helm
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-controllerserviceAccount.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:
kubectl get deployment -n kube-system aws-load-balancer-controller
# READY 2/2
kubectl logs -n kube-system deploy/aws-load-balancer-controller | headTwo replicas with leader election is the default — one active, one standby.
Step 4: Expose an App Through an ALB
Deploy something to route to:
kubectl create deployment echo --image=ealen/echo-server:latest --replicas=2
kubectl expose deployment echo --port=80Now the part that previously did nothing — an Ingress:
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: 80Two 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.
kubectl apply -f ingress.yaml
kubectl get ingress echo -wThe ADDRESS column populates with an ALB DNS name in ~2–3 minutes (AWS provisioning time, not the controller being slow):
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:
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: 80The 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=1public,internal-elbprivate) — the controller retries automatically. AccessDeniederrors 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 forv3.4.2and 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:
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/AWSLoadBalancerControllerIAMPolicyOfficial References
- Amazon EKS pricing — current control-plane, extended-support and Auto Mode rates
- Elastic Load Balancing pricing — hourly and LCU charges per load balancer type
- AWS Load Balancer Controller docs — installation, annotations, and the Gateway API guide
- Installation guide — including Pod Identity and non-EKS clusters
- Ingress annotations reference — the full ALB tuning surface
- eks-charts Helm chart — chart values and CRD upgrade notes
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.