Skip to main content

How to Deploy Kubernetes Services using Gateway API/AWS Load Balancer Controller

· 9 min read
Kobbi Gal (Akeyless)
Escalations Engineer at Akeyless

This tutorial contains a working example of exposing TCP services (LDAP/LDAPS + SSH) from a single-node k3s cluster running on an EC2 instance, using:

  • Kubernetes Gateway API
  • AWS Load Balancer Controller (LBC) for:
    • NLB (L4) via TCPRoute
    • ALB (L7) via HTTPRoute/GRPCRoute (example file included)

The key implementation detail for k3s-on-EC2 with the default overlay networking (flannel): use instance targets + NodePorts for L4 routes. ClusterIP + pod IP targets won’t work unless pods are VPC-routable (AWS VPC CNI).

Environment​

  • Node OS: Ubuntu 24.04.4 LTS
  • Kernel: 6.14.0-1018-aws
  • k3s / server: v1.33.4+k3s1
  • containerd: 2.0.5-k3s2
  • kubectl client: v1.35.0 (warning: > +/-1 minor skew vs server)
  • Gateway API: 1.3.0
  • AWS Load Balancer Controller
    • Helm chart: aws-load-balancer-controller-3.0.0 (app version v3.0.0)
    • Controller image: public.ecr.aws/eks/aws-load-balancer-controller:v2.17.0
    • Flags:
      • --feature-gates=ALBGatewayAPI=true,NLBGatewayAPI=true
      • --enable-manage-backend-security-group-rules=true

Prerequisites​

  • An EC2 instance with k3s installed and reachable via SSH (example host alias: $DOMAIN_NAME)
  • AWS credentials able to create/modify:
    • ELBv2 load balancers/listeners/target groups
    • EC2 security groups and tags
    • (plus whatever IAM is required by the controller)
  • kubectl, helm, aws CLI installed locally

Files​

Gateway API standard CRDs:

wget --output-document standard_crds_1.3.0.yaml https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.3.0/standard-install.yam

Gateway API experimental CRDs (needed for some L4 routes depending on version)

wget --output-document experimental_crds_1.3.0.yaml https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.3.0/experimental-install.yaml

AWS LBC Gateway API CRDs (gateway.k8s.aws/*):

wget --output-document aws_lbc_gateway-crds_3.0.yaml https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/refs/heads/release-3.0/config/crd/gateway/gateway-crds.yaml

Helm values used to install/upgrade AWS LBC:

helm show values eks/aws-load-balancer-controller > aws-lbc.yaml

GatewayClasses definitions for both the ALB and NLB:

00-gatewayclasses.yaml
apiVersion: "gateway.networking.k8s.io/v1"
kind: GatewayClass
metadata:
name: aws-lbc-alb
spec:
controllerName: gateway.k8s.aws/alb
parametersRef:
group: gateway.k8s.aws
kind: LoadBalancerConfiguration
name: alb-public-config
namespace: default
---
apiVersion: "gateway.networking.k8s.io/v1"
kind: GatewayClass
metadata:
name: aws-lbc-nlb
spec:
controllerName: gateway.k8s.aws/nlb
parametersRef:
group: gateway.k8s.aws
kind: LoadBalancerConfiguration
name: nlb-public-config
namespace: default

LoadBalancerConfiguration for public ALB and NLB:

12-lb-public-lbc.yaml
apiVersion: gateway.k8s.aws/v1beta1
kind: LoadBalancerConfiguration
metadata:
name: alb-public-config
namespace: default
spec:
scheme: internet-facing
loadBalancerSubnets:
- identifier: subnet-1
- identifier: subnet-2
---
apiVersion: gateway.k8s.aws/v1beta1
kind: LoadBalancerConfiguration
metadata:
name: nlb-public-config
namespace: default
spec:
scheme: internet-facing
loadBalancerAttributes:
- key: load_balancing.cross_zone.enabled
value: "true"
loadBalancerSubnets:
- identifier: subnet-3
- identifier: subnet-4
- identifier: subnet-5

ALB HTTP 443 Gateway:

10-gateway-alb.yaml
apiVersion: "gateway.networking.k8s.io/v1"
kind: Gateway
metadata:
name: public-alb-gw
namespace: default
spec:
gatewayClassName: aws-lbc-alb
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.$DOMAIN_NAME"
tls:
mode: Terminate
certificateRefs:
- kind: Secret
group: ""
name: tls-$DOMAIN_NAME-crt
allowedRoutes:
namespaces:
from: All
note

You need to make sure that there's a TLS secret named tls-$DOMAIN_NAME-crt as referenced by the Gateway that has SAN that includes the hostname provided above. To create it:

kubectl create secret tls tls-$DOMAIN_NAME-crt --cert=$DOMAIN_NAME.crt.pem --key=deploy/ingress/certs/$DOMAIN_NAME.key.pem -n default

NLB TCP 636 and 2222 Gateway:

11-gateway-nlb.yaml
apiVersion: "gateway.networking.k8s.io/v1"
kind: Gateway
metadata:
name: public-nlb-gw
namespace: default
spec:
gatewayClassName: aws-lbc-nlb
listeners:
- name: ldaps
protocol: TCP
port: 636
allowedRoutes:
namespaces:
from: All

- name: ssh
protocol: TCP
port: 2222
allowedRoutes:
namespaces:
from: All

LDAP TCPRoute:

20-route-ldap-tcp.yaml
apiVersion: "gateway.networking.k8s.io/v1alpha2"
kind: TCPRoute
metadata:
name: ldap-ldaps-route
namespace: ldap
spec:
parentRefs:
- name: public-nlb-gw
namespace: default
sectionName: ldaps
rules:
- backendRefs:
- name: ldap
port: 636

SSH TCPRoute:

21-route-ssh-tcp.yaml
apiVersion: "gateway.networking.k8s.io/v1alpha2"
kind: TCPRoute
metadata:
name: ssh-tcp-route
namespace: ssh
spec:
parentRefs:
- name: public-nlb-gw
namespace: default
sectionName: ssh
rules:
- backendRefs:
- name: ssh
port: 2222

Example HTTPRoute:

30-route-http-example.yaml
# 30-route-http-example.yaml (optional example)
# Replace namespace/service/port with your actual HTTP app
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: example-web-route
namespace: default
spec:
parentRefs:
- name: public-alb-gw
namespace: default
sectionName: https
hostnames:
- app.$DOMAIN_NAME
rules:
- backendRefs:
- name: example-web-svc
port: 80

Deployment​

1. Set Convenience Variables​

AWS_PROFILE=dev
AWS_REGION=$AWS_REGION

2. Install Gateway API CRDs and AWS LBC Gateway CRDs​

Apply the CRDs from this directory:

kubectl apply -f standard_crds_1.3.0.yaml
kubectl apply -f experimental_crds_1.3.0.yaml
kubectl apply -f aws_lbc_gateway-crds_3.0.yaml

Verify the TCPRoute CRD is present and served:

kubectl get crd tcproutes.gateway.networking.k8s.io
kubectl get crd tcproutes.gateway.networking.k8s.io -o jsonpath='{.spec.versions[*].name}{"\n"}'

3. Install / upgrade AWS Load Balancer Controller (Helm)​

This repo uses the EKS chart with explicit values in aws-lbc.yaml.

helm repo add eks https://aws.github.io/eks-charts
helm repo update

helm upgrade --install aws-lbc eks/aws-load-balancer-controller \
-n kube-system \
-f aws-lbc.yaml \
--version 3.0.0

Confirm the controller flags include the Gateway API feature gates:

kubectl  -n kube-system get deploy aws-lbc-aws-load-balancer-controller \
-o jsonpath='{.spec.template.spec.containers[0].args}{"\n"}'

You should see:

  • --feature-gates=ALBGatewayAPI=true,NLBGatewayAPI=true

For k3s/flannel + NLB instance targets, it’s helpful to also run:

  • --enable-manage-backend-security-group-rules=true

4. Apply LBC LoadBalancerConfiguration, GatewayClass, and Gateway​

kubectl apply -f 12-lb-public-lbc.yaml
kubectl apply -f 00-gatewayclasses.yaml
kubectl apply -f 11-gateway-nlb.yaml

Important bits from 11-gateway-nlb.yaml (listeners define the NLB ports):

spec:
gatewayClassName: aws-lbc-nlb
listeners:
- name: ldaps
protocol: TCP
port: 636
- name: ssh
protocol: TCP
port: 2222

Verify the NLB Gateway gets an address:

kubectl  -n default get gateway public-nlb-gw -o wide
kubectl -n default get gateway public-nlb-gw -o yaml | rg -n "addresses:|value:|listeners:|attachedRoutes" -n

5. Deploy LDAP + SSH Workloads and NodePort Services​

For L4 NLB on k3s/flannel, Services must be NodePort, because the controller will create instance target groups that point to the node’s NodePorts.

Important snippets (from ../ldap/service.yaml and ../ssh/service.yaml in this repo):

spec:
type: NodePort
ports:
- name: ldaps
port: 636
targetPort: 636
nodePort: 30636
spec:
type: NodePort
ports:
- name: ssh
port: 2222
targetPort: 2222
nodePort: 32222

Apply your workloads/services (paths in this repo):

kubectl  apply -f ../ldap/deployment.yaml
kubectl apply -f ../ldap/service.yaml

kubectl apply -f ../ssh/namespace.yaml
kubectl apply -f ../ssh/configmap.yaml
kubectl apply -f ../ssh/deployment.yaml
kubectl apply -f ../ssh/service.yaml

Verify endpoints exist:

kubectl  -n ldap get endpointslices -l kubernetes.io/service-name=ldap
kubectl -n ssh get endpointslices -l kubernetes.io/service-name=ssh

6. Create TCPRoutes to attach Services to Gateway listeners​

kubectl  apply -f 20-route-ldap-tcp.yaml
kubectl apply -f 21-route-ssh-tcp.yaml

Important route bits:

spec:
parentRefs:
- name: public-nlb-gw
namespace: default
sectionName: ldaps # matches listener name
rules:
- backendRefs:
- name: ldap
port: 636

Check route status:

kubectl  -n ldap get tcproute ldap-ldaps-route -o yaml
kubectl -n ssh get tcproute ssh-tcp-route -o yaml

You want status.parents[*].conditions to include Accepted=True and ResolvedRefs=True.

7. Verify AWS Resources (listeners + target groups + health)​

Get the NLB hostname from the Gateway:

kubectl  -n default get gateway public-nlb-gw -o jsonpath='{.status.addresses[0].value}{"\n"}'

Resolve and test from your machine:

NLB_HOST=$(kubectl -n default get gateway public-nlb-gw -o jsonpath='{.status.addresses[0].value}')
dig +short "$NLB_HOST"
nc -G 2 -vz "$NLB_HOST" 636
nc -G 2 -vz "$NLB_HOST" 2222

Confirm listeners exist in AWS:

aws elbv2 describe-listeners \
--load-balancer-arn "arn:aws:elasticloadbalancing:$AWS_REGION:$AWS_ACCOUNT_ID:loadbalancer/net/<name>/<id>" \
--profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager

List target groups and check target health:

aws elbv2 describe-target-groups \
--load-balancer-arn "arn:aws:elasticloadbalancing:$AWS_REGION:$AWS_ACCOUNT_ID:loadbalancer/net/<name>/<id>" \
--profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager

aws elbv2 describe-target-health \
--target-group-arn "<target-group-arn>" \
--profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager

8. Validate NodePorts locally on the EC2 node (via SSH)​

This is a fast way to separate “Kubernetes service routing” from “AWS/NLB connectivity”.

ssh $DOMAIN_NAME 'nc -vz -w 2 127.0.0.1 30636; nc -vz -w 2 127.0.0.1 32222'

For a single node in one AZ, an internet-facing NLB in multiple AZs can resolve to IPs in AZs that have no healthy targets. You have two options:

  • Enable cross-zone load balancing (recommended)
  • Or constrain the NLB to only the node’s subnet/AZ

This repo enables cross-zone in 12-lb-public-lbc.yaml:

spec:
loadBalancerAttributes:
- key: load_balancing.cross_zone.enabled
value: "true"

Verify in AWS:

aws elbv2 describe-load-balancer-attributes \
--load-balancer-arn "<nlb-arn>" \
--profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager \
--query 'Attributes[?Key==`load_balancing.cross_zone.enabled`]'

Troubleshooting​

During the deployment, I found the following commands helpful to use troubleshoot what was wrong with the deployment:

A) Gateway has an address, but NLB has no listeners​

Check controller logs; the exact message that pointed to the root cause was:

kubectl  -n kube-system logs deploy/aws-lbc-aws-load-balancer-controller --since=2h | rg -n "Skipping listener creation|public-nlb-gw"

If you see:

  • Skipping listener creation due to no backend references

…it usually means the controller couldn’t materialize a usable backend (commonly: ClusterIP-only backends on overlay networking).

B) Confirm TCPRoute attachment and backend resolution​

kubectl  -n ldap get tcproute ldap-ldaps-route -o yaml
kubectl -n ssh get tcproute ssh-tcp-route -o yaml
kubectl -n default describe gateway public-nlb-gw

C) Verify Services/EndpointSlices​

kubectl  -n ldap get svc ldap -o yaml
kubectl -n ssh get svc ssh -o yaml
kubectl -n ldap get endpointslices -l kubernetes.io/service-name=ldap -o yaml
kubectl -n ssh get endpointslices -l kubernetes.io/service-name=ssh -o yaml

D) Inspect AWS listeners / target groups / target health​

aws elbv2 describe-listeners --load-balancer-arn "<nlb-arn>" --profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager
aws elbv2 describe-target-groups --load-balancer-arn "<nlb-arn>" --profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager
aws elbv2 describe-target-health --target-group-arn "<tg-arn>" --profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager

E) Security groups (NodePort/instance targets)​

For instance targets, NLB health checks hit the node’s NodePort. If the node SG blocks that port, targets become unhealthy and connections time out.

Commands to inspect SG rules:

aws ec2 describe-security-groups --group-ids "<node-sg>" --profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager

To allow the NLB/backend SG to reach NodePorts (example):

aws ec2 authorize-security-group-ingress \
--group-id "<node-sg>" \
--ip-permissions \
'IpProtocol=tcp,FromPort=30636,ToPort=30636,UserIdGroupPairs=[{GroupId=<backend-sg>}]' \
'IpProtocol=tcp,FromPort=32222,ToPort=32222,UserIdGroupPairs=[{GroupId=<backend-sg>}]' \
--profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager

Also, the controller’s worker-node SG selection expects a SG tagged like:

  • kubernetes.io/cluster/<cluster-name> = owned|shared

F) Subnets / AZ mismatch​

If the node is in $AWS_REGIONd but the NLB only has subnets in 1b/1c, targets can’t be registered/used correctly.

kubectl  get nodes -o wide
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.providerID}{"\n"}{end}'

aws ec2 describe-instances --instance-ids "<i-...>" --profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager
aws ec2 describe-subnets --subnet-ids "<subnet-1>" "<subnet-2>" --profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager

aws elbv2 describe-load-balancers --load-balancer-arns "<nlb-arn>" --profile "$AWS_PROFILE" --region "$AWS_REGION" --no-cli-pager \
--query 'LoadBalancers[].AvailabilityZones[].{Zone:ZoneName,SubnetId:SubnetId}'

G) Multi-AZ NLB resolves to multiple IPs (some work, some time out)​

dig +short "$NLB_HOST"
for ip in $(dig +short "$NLB_HOST"); do
nc -G 2 -vz "$ip" 2222 || true
done

Fix: enable cross-zone (load_balancing.cross_zone.enabled=true) or restrict subnets.

Notes / gotchas​

  • ClusterIP services + overlay pod IPs: You’ll often get Accepted=True on routes, but the controller still can’t create listeners/targets (or will skip them) because it can’t reach pod IPs from the NLB.
  • kubectl version skew: in this environment, client is newer than server by >1 minor. It still works for these resources, but keep it in mind if you hit strange client-side issues.