Implementing Kubernetes Gateway API using AWS Load Balancer Controller - part I: L4 (NLBGatewayAPI)
The open-source AWS Load Balancer Controller supports Gateway API in GA (General Available) since v3.0.0.
This two-part article series provides a comprehensive example of installing AWS Load Balancer Controller (LBC) with Gateway API support, using Pod Identity and two sample implementations using L4 (NLBGatewayAPI) and L7 ((ALBGatewayAPI) routing and sample AWS CLI and kubectl outputs for reference.
Background
The open-source AWS Load Balancer Controller supports Gateway API in GA (General Available) since v3.0.0.
This fact and customer demand for implementing a Gateway API using AWS Load Balancer Controller (LBC), led to the creation of this article.
Prerequisites
To follow this post you need a running EKS cluster with EKS managed add-on eks-pod-identity-agent with a recent version and uses the LBC Helm chart app version v2.17.1.
Create the AWS service account using Pod Identity
The following steps show the necessary steps to create all IAM and Pod Identity related resources for the LBC service account (SA) aws-load-balancer-controller.
If you want to use a different SA, please change the following examples accordingly.
# Download the IAM policy document
$ curl -sSLO https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.17.1/docs/install/iam_policy.json
# create the IAM policy
$ aws iam create-policy --policy-name AWSLoadBalancerControllerIAMPolicy --policy-document file://iam_policy.json
{
"Policy": {
"PolicyName": "AWSLoadBalancerControllerIAMPolicy",
"PolicyId": "ANPA<redacted>",
"Arn": "arn:aws:iam::<redacted>:policy/AWSLoadBalancerControllerIAMPolicy",
"Path": "/",
"DefaultVersionId": "v1",
"AttachmentCount": 0,
"PermissionsBoundaryUsageCount": 0,
"IsAttachable": true,
"CreateDate": "2026-03-12T10:54:21+00:00",
"UpdateDate": "2026-03-12T10:54:21+00:00"
}
}
# Create the trust policy for the IAM role
$ cat aws-lb-ctlr-trust-policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "pods.eks.amazonaws.com"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
]
}
]
}
# Create an IAM role called AWSLbCtlrRole
$ aws iam create-role --role-name AWSLbCtlrRole --assume-role-policy-document file://aws-lb-ctlr-trust-policy
{
"Role": {
"Path": "/",
"RoleName": "AWSLbCtlrRole",
"RoleId": "AROA<redacted>",
"Arn": "arn:aws:iam::<redacted>:role/AWSLbCtlrRole",
"CreateDate": "2026-03-12T10:59:46+00:00",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "pods.eks.amazonaws.com"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
]
}
]
}
}
}
# Attach the previously downloaded IAM policy to the role
$ aws iam attach-role-policy --role-name AWSLbCtlrRole --policy-arn arn:aws:iam::<redacted>:policy/AWSLoadBalancerControllerIAMPolicy
$ aws iam list-attached-role-policies --role-name AWSLbCtlrRole
{
"AttachedPolicies": [
{
"PolicyName": "AWSLoadBalancerControllerIAMPolicy",
"PolicyArn": "arn:aws:iam::<redacted>:policy/AWSLoadBalancerControllerIAMPolicy"
}
]
}
# create a Pod Identity association for the SA
$ aws eks create-pod-identity-association --cluster-name <cluster name>-demo --namespace kube-system --service-account aws-load-balancer-controller --role-arn arn:aws:iam::<redacted>:role/AWSLbCtlrRole
{
"association": {
"clusterName": "karpenter-demo",
"namespace": "kube-system",
"serviceAccount": "aws-load-balancer-controller",
"roleArn": "arn:aws:iam::<redacted>:role/AWSLbCtlrRole",
"associationArn": "arn:aws:eks:eu-west-1:618668373247:podidentityassociation/<cluster name>/a-<redacted>",
"associationId": "a-<redacted>",
"tags": {},
"createdAt": "2026-03-12T12:03:25.017000+01:00",
"modifiedAt": "2026-03-12T12:03:25.017000+01:00",
"disableSessionTags": false
}
}
AWS Load Balancer Controller installation
Gateway API support for LBC is not enabled by default and has to be enabled by turning on the respective featureGates.
The following Helm values.yaml file is used for customisation of the installation :
clusterName: <cluster name>
serviceAccount:
create: true
name: aws-load-balancer-controller
# controllerConfig specifies controller configuration
controllerConfig:
# featureGates set of key: value pairs that describe AWS load balance controller features
featureGates:
NLBGatewayAPI: true
ALBGatewayAPI: true
# Set the controller log level - info(default), debug (default "info")
# logLevel: debug
# the following section is needed if AL2023 and Karpenter are used, which by default block IMDS access for pods
# The AWS region for the kubernetes cluster.
region: <region>
# The VPC ID for the Kubernetes cluster.
vpcId: <vpc-id>
Installation is done using the Helm chart as follows:
$ helm install aws-load-balancer-controller eks/aws-load-balancer-controller -n kube-system --set clusterName=karpenter-demo --set serviceAccount.create=true --set serviceAccount.name=aws-load-balancer-controller
NAME: aws-load-balancer-controller
LAST DEPLOYED: Thu Mar 12 12:10:18 2026
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
AWS Load Balancer controller installed!
$ helm list -n kube-system
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
aws-load-balancer-controller kube-system 1 2026-03-12 12:10:18.718696 +0100 CET deployed aws-load-balancer-controller-1.17 v2.17.1
# check that the deployment is running
$ kubectl get deploy -n kube-system aws-load-balancer-controller
NAME READY UP-TO-DATE AVAILABLE AGE
aws-load-balancer-controller 2/2 2 2 40s
$ kubectl get po -n kube-system -l=app.kubernetes.io/name=aws-load-balancer-controller
NAME READY STATUS RESTARTS AGE
aws-load-balancer-controller-856755cd78-mm54g 1/1 Running 0 114s
aws-load-balancer-controller-856755cd78-tp74g 1/1 Running 0 114s
Quickstart using AWS Load Balancer Controller as a Kubernetes Gateway AP
According to the LBC documentation v3.1.0 the Gateway API implementation supports the following Gateway API routes:
- L4 (NLBGatewayAPI): UDPRoute, TCPRoute, TLSRoute >=v2.13.3
- L7 (ALBGatewayAPI): HTTPRoute, GRPCRoute >= 2.14.0 The LBC is built for Gateway API version v1.3.0.
For the core CustomeResourceDefinitions (CRD) and the basic idea behind Gateway API read upstream doc Introduction.
Gateway API v1 CRD consists of the following CRD:
$ kubectl api-resources | grep -w gateway.networking.k8s.io/v1
backendtlspolicies btlspolicy gateway.networking.k8s.io/v1 true BackendTLSPolicy
gatewayclasses gc gateway.networking.k8s.io/v1 false GatewayClass
gateways gtw gateway.networking.k8s.io/v1 true Gateway
grpcroutes gateway.networking.k8s.io/v1 true GRPCRoute
httproutes gateway.networking.k8s.io/v1 true HTTPRoute
Common configurations
LBC utilises upstream Gateway API CRD to implement the AWS specific configurations of the provisioned Elastic Load Balancers (ELB), which are reached v1beta1 API status.
Note: LBC Gateway API implementation does not support an annotation based approach!
$ kubectl api-resources | grep gateway.k8s.aws/v1beta1
listenerruleconfigurations gateway.k8s.aws/v1beta1 true ListenerRuleConfiguration
loadbalancerconfigurations gateway.k8s.aws/v1beta1 true LoadBalancerConfiguration
targetgroupconfigurations gateway.k8s.aws/v1beta1 true TargetGroupConfiguration
I use a slightly modified version of Kubernetes backend deployment and service as described in re:Post article Implementing Kubernetes Gateway API in EKS Auto Mode using Envoy Gateway with the following YAML:
quickstart.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: backend
---
apiVersion: v1
kind: Service
metadata:
name: backend
labels:
app: backend
service: backend
spec:
ports:
- name: http
port: 3000
targetPort: 3000
selector:
app: backend
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 1
selector:
matchLabels:
app: backend
version: v1
template:
metadata:
labels:
app: backend
version: v1
spec:
serviceAccountName: backend
containers:
- image: gcr.io/k8s-staging-gateway-api/echo-basic:v20231214-v1.0.0-140-gf544a46e
imagePullPolicy: IfNotPresent
name: backend
ports:
- containerPort: 3000
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
---
Create a custom LoadBalancerConfiguration to modify the ELB scheme from default internal to internet-facing !!!
lbconfig.yaml
apiVersion: gateway.k8s.aws/v1beta1
kind: LoadBalancerConfiguration
metadata:
name: internet-facing-config
spec:
scheme: internet-facing
and apply it to the cluster:
$ kubectl apply -f lbconfig.yaml
loadbalancerconfiguration.gateway.k8s.aws/internet-facing-config created
Create a custom TargetGroupConfiguration to modify the targetType for (K8s) service backend from default instance to ip !!!
tgconfig.yaml
apiVersion: gateway.k8s.aws/v1beta1
kind: TargetGroupConfiguration
metadata:
name: custom-tg-config
spec:
targetReference:
name: backend
defaultConfiguration:
targetType: ip
and apply it to the cluster:
$ kubectl apply -f tgconfig.yaml
targetgroupconfiguration.gateway.k8s.aws/custom-tg-config created
L4 (NLBGatewayAPI) routing.
LBC comes with two controllers. L4 routing uses the controller gateway.k8s.aws/nlb and provisions NLB.
First create the GatewayClass:
gwc-nlb.yaml
apiVersion: gateway.networking.k8s.io/v1beta1
kind: GatewayClass
metadata:
name: aws-nlb-gateway-class
spec:
controllerName: gateway.k8s.aws/nlb
apply it to the cluster and check that config is ACCEPTED: true:
$ kubectl get gc aws-nlb-gateway-class
NAME CONTROLLER ACCEPTED AGE
aws-nlb-gateway-class gateway.k8s.aws/nlb True 8h
Now create the Gateway resource which references the LoadBalancerConfiguration internet-facing-config we created earlier:
gw-public-nlb.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: aws-tcp-gateway
spec:
gatewayClassName: aws-nlb-gateway-class
infrastructure:
parametersRef:
kind: LoadBalancerConfiguration
name: internet-facing-config
group: gateway.k8s.aws
listeners:
- name: tcp-app
protocol: TCP
port: 8080
allowedRoutes:
namespaces:
from: Same
apply it to the cluster and check the ADDRESS. A NLB will be provisioned, which takes some time until PROGRAMMED shows True:
$ kubectl apply -f gw-public-nlb.yaml
gateway.gateway.networking.k8s.io/aws-tcp-gateway created
$ kubectl get gateway aws-tcp-gateway
NAME CLASS ADDRESS PROGRAMMED AGE
aws-tcp-gateway aws-nlb-gateway-class k8s-default-awstcpga-<redacted1>-<redacted>.elb.eu-west-1.amazonaws.com Unknown 11s
One can check the NLB using the following AWS elbv2 commands for reference:
# ELB is an internet-facing NLB (Type: network)
$ aws elbv2 describe-load-balancers --names k8s-default-awstcpga-<redacted1>
{
"LoadBalancers": [
{
"LoadBalancerArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted1>/<redacted2>",
"DNSName": "k8s-default-awstcpga-<redacted1>-<redacted2>.elb.eu-west-1.amazonaws.com",
"CanonicalHostedZoneId": "<redacted>",
"CreatedTime": "2026-03-16T08:55:36.371000+00:00",
"LoadBalancerName": "k8s-default-awstcpga-<redacted1>",
"Scheme": "internet-facing",
"VpcId": "vpc-<redacted>",
"State": {
"Code": "provisioning"
},
"Type": "network",
...
This NLB does not yet have a listener provisioned, even though it is referenced already as spec.listeners.name: tcp-app, because this still requires a TCPRoutes resource:
$ aws elbv2 describe-listeners --load-balancer-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted1>/<redacted2>
{
"Listeners": []
}
Note the API version of TCPRoutes (and UDPRoutes), which is still v1aplpha2` and subject to change !!!
$ kubectl api-resources | grep tcproute
tcproutes gateway.networking.k8s.io/v1alpha2 true TCPRoute
$ kubectl api-resources | grep gateway.networking.k8s.io/v1alpha2
tcproutes gateway.networking.k8s.io/v1alpha2 true TCPRoute
udproutes gateway.networking.k8s.io/v1alpha2 true UDPRoute
The TCPRoutes resource YAML looks like:
tcproute.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata:
name: tcp-backend-aws
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: aws-tcp-gateway
sectionName: tcp-app # Refers to the specific listener on the Gateway
rules:
- backendRefs:
- name: backend
port: 3000
$ kubectl apply -f tcproute.yaml
tcproute.gateway.networking.k8s.io/tcp-backend-aws created
$ kubectl get tcproutes.gateway.networking.k8s.io
NAME AGE
tcp-backend-aws 5s
Now check the NLB for related AWS resources listener, rules, target groups and target health:
# describe NLB listener, which listens on TCP port 8080 and has a FORWARD config with a target group
$ aws elbv2 describe-listeners --load-balancer-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted1>/<redacted2>
{
"Listeners": [
{
"ListenerArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:listener/net/k8s-default-awstcpga-<redacted1>/<redacted2>/<redacted3>",
"LoadBalancerArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted1>/<redacted2>",
"Port": 8080,
"Protocol": "TCP",
"DefaultActions": [
{
"Type": "forward",
"TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
"Order": 1,
"ForwardConfig": {
"TargetGroups": [
{
"TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
"Weight": 1
}
],
"TargetGroupStickinessConfig": {
"Enabled": false
}
}
}
]
}
]
}
# describe NLB listener forward rule to target group
$ aws elbv2 describe-rules --listener-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:listener/net/k8s-default-awstcpga-<redacted1>/<redacted2>/<redacted3>
{
"Rules": [
{
"RuleArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:listener-rule/net/k8s-default-awstcpga-<redacted1>/<redacted2>/<redacted3>/<redacted4>",
"Priority": "default",
"Conditions": [],
"Actions": [
{
"Type": "forward",
"TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
"Order": 1,
"ForwardConfig": {
"TargetGroups": [
{
"TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
"Weight": 1
}
],
"TargetGroupStickinessConfig": {
"Enabled": false
}
}
}
],
"IsDefault": true,
"Transforms": []
}
]
}
# describe NLB target group, which is of targetType: ip and is using targets from backend service on TCP port 3000
$ aws elbv2 describe-target-groups --load-balancer-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted>/<redacted>
{
"TargetGroups": [
{
"TargetGroupArn": "arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>",
"TargetGroupName": "k8s-default-tcpbacke-<redacted>",
"Protocol": "TCP",
"Port": 3000,
"VpcId": "vpc-<redacted>",
"HealthCheckProtocol": "TCP",
"HealthCheckPort": "traffic-port",
"HealthCheckEnabled": true,
"HealthCheckIntervalSeconds": 15,
"HealthCheckTimeoutSeconds": 5,
"HealthyThresholdCount": 3,
"UnhealthyThresholdCount": 3,
"LoadBalancerArns": [
"arn:aws:elasticloadbalancing:eu-west-1:<redacted>:loadbalancer/net/k8s-default-awstcpga-<redacted>/<redacted>"
],
"TargetType": "ip",
"IpAddressType": "ipv4"
}
]
}
# targets are the backend deployment pod(s)
$ aws elbv2 describe-target-health --target-group-arn arn:aws:elasticloadbalancing:eu-west-1:<redacted>:targetgroup/k8s-default-tcpbacke-<redacted>/<redacted>
{
"TargetHealthDescriptions": [
{
"Target": {
"Id": "192.168.165.118",
"Port": 3000,
"AvailabilityZone": "eu-west-1c"
},
"HealthCheckPort": "3000",
"TargetHealth": {
"State": "healthy"
},
"AdministrativeOverride": {
"State": "no_override",
"Reason": "AdministrativeOverride.NoOverride",
"Description": "No override is currently active on target"
}
}
]
}
# backend K8s pod
$ kubectl get pod backend-86c6c76f-vwbxt -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
backend-86c6c76f-vwbxt 1/1 Running 0 4d8h 192.168.165.118 ip-<redacted>.eu-west-1.compute.internal <none> <none>
Finally create a Route53 alias record pointing to the NLB, check it and use curl to generate some traffic:
$ aws route53 list-resource-record-sets --hosted-zone-id <public hosted zoneID> --query 'ResourceRecordSets[?Name==`aws-k8s-gw-nlb.<redacted domain name>`]'
[
{
"Name": "aws-k8s-gw-nlb.<redacted domain name>",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2IFOLAFXWLO4F",
"DNSName": "k8s-default-awstcpga-<redacted1>-<redacted2>.elb.eu-west-1.amazonaws.com.",
"EvaluateTargetHealth": true
}
}
]
$ dig +short aws-k8s-gw-nlb.<redacted domain name>
<public NLB IP>
The NLB GW was created with listeners on port 8080 !!!
$ curl -s -o /dev/null -w "%{http_code}\n" aws-k8s-gw-nlb.<redacted>:8080
200
$ curl aws-k8s-gw-nlb.<redacted domain name>:8080
{
"path": "/",
"host": "aws-k8s-gw-nlb.<redacted domain name>:8080",
"method": "GET",
"proto": "HTTP/1.1",
"headers": {
"Accept": [
"*/*"
],
"User-Agent": [
"curl/8.7.1"
]
},
"namespace": "default",
"ingress": "",
"service": "",
"pod": "backend-86c6c76f-vwbxt"
}
This completes the "L4 (NLBGatewayAPI)" part of this rePost series.
Part II will cover "L7 (ALBGatewayAPI)".
- Topics
- Containers
- Language
- English
Relevant content
- Accepted Answerasked 4 years ago
- Accepted Answer
