Back to Blog Hub

Ingress vs ALB vs NLB in EKS — Real Traffic Routing Patterns Explained

May 17, 2026 22 min read Kubernetes Networking

AWS Series | Part 12 — Building secure, cost-optimised, cloud-native infrastructure on AWS.

EKS Ingress ALB NLB Traffic Routing Architecture Diagram

TL;DR Comparison

Kubernetes Ingress (Nginx) AWS ALB Ingress Controller AWS NLB (Service LoadBalancer)
OSI Layer Layer 7 (HTTP/HTTPS) Layer 7 (HTTP/HTTPS) Layer 4 (TCP/UDP/TLS)
Traffic routing Path + host based Path + host based Port based only
TLS termination At Nginx pod At ALB At NLB or pass-through
WebSocket support ✅ Native ✅ Native ✅ Native
gRPC support ✅ Native ✅ HTTP/2 required ✅ Native (TCP passthrough)
WAF integration ❌ Manual ✅ Native annotation ❌ Not supported
Target type ClusterIP via Service Pod IP direct (ip mode) Pod IP or Node port
Cross-namespace routing ✅ Via IngressClass ❌ Per-namespace ❌ Per-namespace
Static IP ❌ (DNS only) ✅ Elastic IP
Preserve client IP ✅ Via X-Forwarded-For ✅ Via X-Forwarded-For ✅ Native (Layer 4)
Cost EC2 nodes for Nginx pods Per ALB + LCU charges Per NLB + LCU charges
Best for Complex routing, cross-namespace, cost at scale AWS-native, WAF, simple path routing gRPC, static IP, TCP workloads, ultra-low latency

Introduction

EKS networking trips up engineers at every seniority level. The question sounds simple: how do I get traffic from the internet into my pods? The answer has at least five legitimate options, each with different trade-offs that matter enormously at production scale.

I have seen three patterns used at the Rabobank fraud detection platform — Nginx Ingress for the application tier, NLB for the message broker (Amazon MQ), and ALB for the ArgoCD UI. Each was the right choice for its specific traffic pattern. Using the wrong one is not just a performance problem — it's a cost problem, a security problem, and sometimes a capability problem (try routing gRPC through a Layer 7 ALB without HTTP/2 and watch it fail silently).

This post covers the full picture: what each option actually does at the packet level, when each is the right choice, the full Terraform and Kubernetes YAML for every pattern, the production gotchas that documentation doesn't mention, and the architecture I'd use starting fresh today.


1. The Traffic Path — What Actually Happens

Before choosing any option, understand the full traffic path into your EKS cluster:

Internet
    ↓
Route 53 (DNS)
    ↓
Load Balancer (ALB or NLB) — lives in PUBLIC subnets
    ↓
[Option A] Nginx Ingress pods — in PRIVATE subnets (ClusterIP service)
    ↓ (Nginx proxies to)
Application pods — in PRIVATE subnets

[Option B] ALB → Pod IPs directly (ip target mode)
    ↓ (ALB routes to pod ENI directly)
Application pods — in PRIVATE subnets

[Option C] NLB → Pod IPs (ip target mode) or Node ports
    ↓
Application pods — in PRIVATE subnets

The key insight: In all cases, your application pods stay in private subnets. The load balancer is the only component with internet exposure. The question is only about what does the routing between the load balancer and your pods.

2. Kubernetes Ingress with Nginx — Fine-Grained Routing Control

What It Is

An Ingress is a Kubernetes API object that defines routing rules — "requests to /api/fraud go to the fraud service, requests to /api/payments go to the payments service." On its own, an Ingress object does nothing. It requires an Ingress Controller — a pod (or set of pods) that reads the Ingress rules and implements them.

Nginx Ingress Controller is the most widely used. It runs as a Deployment in your cluster, reads all Ingress objects cluster-wide, and programs an Nginx reverse proxy accordingly. An NLB or ALB sits in front of the Nginx pods and forwards all traffic to them — Nginx then handles the Layer 7 routing.

Internet → NLB (L4, TCP passthrough) → Nginx Ingress pods → Application pods

Why Nginx Over the ALB Ingress Controller

At Rabobank, we chose Nginx over the ALB Ingress Controller for the application tier for three specific reasons:

1. Cross-namespace routing — Nginx can route traffic from one Ingress resource to services in different namespaces. The ALB Ingress Controller creates one ALB per Ingress object, and each ALB can only target services in the same namespace. With 33 microservices across 5 namespaces, the ALB approach would have created 33 ALBs — one per service — at ~$22/month each = $726/month in ALB costs alone.

2. Richer annotation set — Nginx supports canary deployments, rate limiting, custom headers, and CORS configuration all via annotations. These patterns are baked into Nginx; replicating them with ALB requires AWS WAF rules and significantly more complexity.

3. WebSocket and gRPC — Nginx handles both without any special configuration. ALB requires alb.ingress.kubernetes.io/backend-protocol-version: GRPC annotation and HTTP/2 enabled on both the ALB and the target service.

Full Nginx Ingress Setup — Terraform + Helm

# NLB for Nginx — Layer 4 passthrough, preserves client IP
resource "helm_release" "nginx_ingress" {
  name             = "ingress-nginx"
  repository       = "https://kubernetes.github.io/ingress-nginx"
  chart            = "ingress-nginx"
  version          = "4.10.1"
  namespace        = "ingress-nginx"
  create_namespace = true

  values = [yamlencode({
    controller = {
      # Use NLB in front of Nginx — not ALB
      # NLB gives us static IPs and true TCP passthrough
      service = {
        type = "LoadBalancer"
        annotations = {
          "service.beta.kubernetes.io/aws-load-balancer-type"            = "external"
          "service.beta.kubernetes.io/aws-load-balancer-nlb-target-type" = "ip"
          "service.beta.kubernetes.io/aws-load-balancer-scheme"          = "internet-facing"
          "service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled" = "true"

          # Static EIP — Nginx NLB gets a fixed IP (useful for allowlisting)
          "service.beta.kubernetes.io/aws-load-balancer-eip-allocations" = join(",", [
            aws_eip.nlb_az_a.allocation_id,
            aws_eip.nlb_az_b.allocation_id,
          ])
        }
      }

      # Resource requests — Nginx pods need headroom for burst traffic
      resources = {
        requests = { cpu = "200m", memory = "256Mi" }
        limits   = { cpu = "1000m", memory = "1Gi" }
      }

      # HA — one Nginx pod per AZ
      replicaCount = 2
      topologySpreadConstraints = [{
        maxSkew           = 1
        topologyKey       = "topology.kubernetes.io/zone"
        whenUnsatisfiable = "DoNotSchedule"
        labelSelector = {
          matchLabels = { "app.kubernetes.io/name" = "ingress-nginx" }
        }
      }]

      # Metrics for Prometheus scraping
      metrics = {
        enabled = true
        serviceMonitor = { enabled = true }
      }

      # Config — tune for production
      config = {
        "use-forwarded-headers"          = "true"
        "compute-full-forwarded-for"      = "true"
        "use-proxy-protocol"             = "false"
        "proxy-body-size"                = "50m"
        "proxy-connect-timeout"          = "10"
        "proxy-send-timeout"             = "60"
        "proxy-read-timeout"             = "60"
        "keep-alive"                     = "75"
        "keep-alive-requests"            = "1000"
        "upstream-keepalive-connections" = "320"
      }
    }
  })]
}

# Elastic IPs — fixed IPs for the NLB
resource "aws_eip" "nlb_az_a" {
  domain = "vpc"
  tags   = { Name = "nginx-nlb-eip-az-a" }
}

resource "aws_eip" "nlb_az_b" {
  domain = "vpc"
  tags   = { Name = "nginx-nlb-eip-az-b" }
}

Ingress Resources — Routing Rules

# fraud-detection ingress — path-based routing to multiple services
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: fraud-detection-ingress
  namespace: fraud-detection
  annotations:
    kubernetes.io/ingress.class: nginx

    # Rate limiting — 100 req/min per IP
    nginx.ingress.kubernetes.io/limit-rps: "100"
    nginx.ingress.kubernetes.io/limit-connections: "20"

    # Timeouts
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "10"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "60"

    # Custom response headers
    nginx.ingress.kubernetes.io/configuration-snippet: |
      add_header X-Frame-Options "DENY" always;
      add_header X-Content-Type-Options "nosniff" always;
      add_header Strict-Transport-Security "max-age=31536000" always;

    # TLS — cert managed by cert-manager
    cert-manager.io/cluster-issuer: letsencrypt-prod

spec:
  tls:
    - hosts:
        - fraud-api.internal.company.com
      secretName: fraud-api-tls

  rules:
    - host: fraud-api.internal.company.com
      http:
        paths:
          # Scoring service — latency-sensitive
          - path: /api/v1/score
            pathType: Prefix
            backend:
              service:
                name: scoring-service
                port:
                  number: 8080

          # Enrichment service
          - path: /api/v1/enrich
            pathType: Prefix
            backend:
              service:
                name: enrichment-service
                port:
                  number: 8080

          # Health check — separate path
          - path: /health
            pathType: Exact
            backend:
              service:
                name: scoring-service
                port:
                  number: 8080
---
# Canary deployment — send 10% of scoring traffic to v2
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: fraud-scoring-canary
  namespace: fraud-detection
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"   # 10% to canary
spec:
  rules:
    - host: fraud-api.internal.company.com
      http:
        paths:
          - path: /api/v1/score
            pathType: Prefix
            backend:
              service:
                name: scoring-service-v2   # Canary service
                port:
                  number: 8080

Cross-Namespace Routing with Nginx

This is the capability that saves the most money in a multi-namespace cluster:

# Single Ingress in the ingress-nginx namespace routing to services
# across multiple namespaces — not possible with ALB Ingress Controller
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: platform-ingress
  namespace: ingress-nginx
  annotations:
    kubernetes.io/ingress.class: nginx
    # Route to fraud-detection namespace
    nginx.ingress.kubernetes.io/upstream-vhost: "scoring-service.fraud-detection.svc.cluster.local"
spec:
  rules:
    - host: platform.internal.company.com
      http:
        paths:
          - path: /fraud
            pathType: Prefix
            backend:
              service:
                name: scoring-service    # ExternalName service pointing cross-namespace
                port:
                  number: 8080
Why this matters in production: The ALB Ingress Controller creates one ALB per Ingress object by default. At 30 services, that is 30 ALBs at ~$22/month each — $660/month for routing that a single Nginx controller fronted by one NLB delivers for ~$36/month. The IngressGroup annotation partially closes this gap for ALB, but Nginx remains the cost-optimal choice for platforms with many services and complex routing requirements. Model the load balancer cost explicitly before choosing your Ingress strategy.

3. AWS ALB Ingress Controller — AWS-Native L7 Routing

What It Is

The AWS Load Balancer Controller is a Kubernetes controller that watches for Ingress objects with ingressClassName: alb and automatically creates and manages AWS Application Load Balancers. Each Ingress creates one ALB. The ALB routes directly to pod IPs (in ip target mode) — no Nginx pod in the middle.

Internet → ALB (managed by AWS LBC) → Pod IPs directly (via VPC ENI)

When ALB Is the Right Choice

  • WAF integration — the ALB Ingress Controller supports attaching a WAF WebACL via a single annotation. This is the cleanest path to Layer 7 protection without additional infrastructure.
  • Simple path routing for a single service — if you have one service per namespace and simple routing needs, one ALB per service is perfectly appropriate.
  • AWS Certificate Manager integration — TLS certificates from ACM attach to ALBs automatically. Nginx requires cert-manager or manual certificate management.
  • Target group reuse across namespaces — the TargetGroupBinding resource lets an ALB target group reference pods in any namespace, partially closing the cross-namespace gap.

Full ALB Ingress Controller Setup

# AWS Load Balancer Controller — IAM role via Pod Identity
resource "aws_iam_role" "aws_lb_controller" {
  name = "aws-load-balancer-controller"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "pods.eks.amazonaws.com" }
      Action    = ["sts:AssumeRole", "sts:TagSession"]
    }]
  })
}

resource "aws_iam_role_policy_attachment" "aws_lb_controller" {
  role       = aws_iam_role.aws_lb_controller.name
  policy_arn = aws_iam_policy.aws_lb_controller.arn
}

resource "aws_eks_pod_identity_association" "aws_lb_controller" {
  cluster_name    = aws_eks_cluster.main.name
  namespace       = "kube-system"
  service_account = "aws-load-balancer-controller"
  role_arn        = aws_iam_role.aws_lb_controller.arn
}

resource "helm_release" "aws_lb_controller" {
  name       = "aws-load-balancer-controller"
  repository = "https://aws.github.io/eks-charts"
  chart      = "aws-load-balancer-controller"
  version    = "1.8.1"
  namespace  = "kube-system"

  set {
    name  = "clusterName"
    value = aws_eks_cluster.main.name
  }

  set {
    name  = "serviceAccount.name"
    value = "aws-load-balancer-controller"
  }

  # HA — 2 replicas across AZs
  set {
    name  = "replicaCount"
    value = "2"
  }

  depends_on = [aws_eks_pod_identity_association.aws_lb_controller]
}

ALB Ingress — Production Configuration

# ALB Ingress — full production annotation set
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: argocd-ingress
  namespace: argocd
  annotations:
    kubernetes.io/ingress.class: alb

    # Scheme — internet-facing or internal
    alb.ingress.kubernetes.io/scheme: internal   # ArgoCD is internal only

    # Target type — ip routes to pod IPs directly (bypass kube-proxy)
    alb.ingress.kubernetes.io/target-type: ip

    # TLS — ACM certificate
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:eu-west-1:123456789012:certificate/xxx
    alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/ssl-redirect: "443"   # Force HTTPS

    # WAF — attach WebACL directly to ALB
    alb.ingress.kubernetes.io/wafv2-acl-arn: arn:aws:wafv2:eu-west-1:123456789012:regional/webacl/prod-waf/xxx

    # Health check
    alb.ingress.kubernetes.io/healthcheck-path: /healthz
    alb.ingress.kubernetes.io/healthcheck-interval-seconds: "15"
    alb.ingress.kubernetes.io/healthcheck-timeout-seconds: "5"
    alb.ingress.kubernetes.io/success-codes: "200"

    # Connection draining — wait for in-flight requests before pod termination
    alb.ingress.kubernetes.io/target-group-attributes: |
      deregistration_delay.timeout_seconds=30,
      slow_start.duration_seconds=30

    # Access logs — ship ALB logs to S3
    alb.ingress.kubernetes.io/load-balancer-attributes: |
      access_logs.s3.enabled=true,
      access_logs.s3.bucket=my-alb-logs-bucket,
      access_logs.s3.prefix=argocd-alb,
      idle_timeout.timeout_seconds=60

    # Security groups
    alb.ingress.kubernetes.io/security-groups: sg-alb-prod

    # Subnets — only deploy ALB into public subnets tagged for ALB discovery
    alb.ingress.kubernetes.io/subnets: subnet-public-az-a,subnet-public-az-b

spec:
  rules:
    - host: argocd.internal.company.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: argocd-server
                port:
                  number: 80

ALB IngressGroup — Share One ALB Across Multiple Ingresses

This is the cost optimisation that changes the ALB cost equation. Without IngressGroup, each Ingress creates one ALB (~$22/month). With IngressGroup, multiple Ingress objects share one ALB:

# Ingress 1 — fraud detection service
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: fraud-detection-ingress
  namespace: fraud-detection
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/group.name: platform-alb    # Shared ALB group
    alb.ingress.kubernetes.io/group.order: "10"           # Rule evaluation order
    alb.ingress.kubernetes.io/scheme: internal
    alb.ingress.kubernetes.io/target-type: ip
spec:
  rules:
    - host: platform.internal.company.com
      http:
        paths:
          - path: /api/fraud
            pathType: Prefix
            backend:
              service:
                name: fraud-scoring-service
                port:
                  number: 8080
---
# Ingress 2 — payments service (different namespace, same ALB)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: payments-ingress
  namespace: payments
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/group.name: platform-alb    # Same group = same ALB
    alb.ingress.kubernetes.io/group.order: "20"
    alb.ingress.kubernetes.io/scheme: internal
    alb.ingress.kubernetes.io/target-type: ip
spec:
  rules:
    - host: platform.internal.company.com
      http:
        paths:
          - path: /api/payments
            pathType: Prefix
            backend:
              service:
                name: payment-processing-service
                port:
                  number: 8080
Cost impact: 10 services without IngressGroup = 10 ALBs = ~$220/month. 10 services with IngressGroup = 1 ALB = ~$22/month + LCU charges. The IngressGroup annotation is the single most impactful cost optimisation in EKS networking.

4. NLB — Layer 4 for gRPC, TCP, and Static IPs

What It Is

A Network Load Balancer operates at Layer 4 — it routes TCP and UDP packets without inspecting HTTP headers. In EKS, you create an NLB by creating a Kubernetes Service of type LoadBalancer with AWS-specific annotations. The AWS Load Balancer Controller provisions and manages the NLB automatically.

Internet → NLB (TCP passthrough) → Pod IPs directly

When NLB Is the Right Choice

  • gRPC workloads — gRPC uses HTTP/2 with long-lived connections and bidirectional streaming. ALB can handle gRPC but requires HTTP/2 on the backend and specific annotations. NLB with TCP passthrough lets gRPC flow through without any protocol awareness — the simplest and most reliable approach.
  • Static IP requirement — ALBs have DNS names that resolve to changing IPs. NLBs support Elastic IPs — fixed public IPs that clients can allowlist. Essential when your clients need to whitelist your ingress IP.
  • Ultra-low latency — NLB has roughly 1ms less latency than ALB because it doesn't inspect packets. For latency-sensitive protocols, this matters.
  • Non-HTTP protocols — WebSockets work on both, but pure TCP/UDP services (game servers, MQTT brokers, database proxies) require NLB.

At Rabobank: Amazon MQ (message broker) was exposed internally via NLB with ip target mode. gRPC services between internal components used NLB for the same reason — TCP passthrough, no protocol inspection overhead.

NLB via Kubernetes Service

# NLB Service — internet-facing with static EIP
apiVersion: v1
kind: Service
metadata:
  name: fraud-grpc-service
  namespace: fraud-detection
  annotations:
    # Use the AWS Load Balancer Controller (not the legacy in-tree controller)
    service.beta.kubernetes.io/aws-load-balancer-type: "external"

    # ip target mode — route to pod IPs directly, not node ports
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"

    # internet-facing or internal
    service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"

    # Cross-zone load balancing — distribute across all AZs
    service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"

    # Static EIPs — fixed IPs for client allowlisting
    service.beta.kubernetes.io/aws-load-balancer-eip-allocations: "eipalloc-xxx,eipalloc-yyy"

    # Health check — TCP check on the gRPC port
    service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol: "TCP"
    service.beta.kubernetes.io/aws-load-balancer-healthcheck-interval: "10"

    # Access logs
    service.beta.kubernetes.io/aws-load-balancer-access-log-enabled: "true"
    service.beta.kubernetes.io/aws-load-balancer-access-log-s3-bucket-name: "nlb-logs-bucket"
    service.beta.kubernetes.io/aws-load-balancer-access-log-s3-bucket-prefix: "fraud-grpc"

    # Connection draining
    service.beta.kubernetes.io/aws-load-balancer-target-group-attributes: |
      deregistration_delay.timeout_seconds=30

spec:
  type: LoadBalancer
  loadBalancerClass: service.k8s.aws/nlb   # Explicit — use AWS LBC, not in-tree

  selector:
    app: fraud-grpc-service

  ports:
    - name: grpc
      port: 443
      targetPort: 50051   # gRPC port on the pod
      protocol: TCP

NLB with TLS Passthrough vs TLS Termination

# TLS passthrough — NLB doesn't decrypt, pod handles TLS
# Use when your gRPC service does its own mTLS
metadata:
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-ssl-cert: ""   # No cert = passthrough

---
# TLS termination at NLB — NLB decrypts, forwards plain TCP to pod
# Use when you want ACM cert management without pod-level TLS handling
metadata:
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-ssl-cert: arn:aws:acm:eu-west-1:123:certificate/xxx
    service.beta.kubernetes.io/aws-load-balancer-ssl-ports: "443"
    service.beta.kubernetes.io/aws-load-balancer-backend-protocol: "tcp"   # Plain TCP to pod after termination

5. The VPC CNI and IP Allocation — The Hidden Bottleneck

This is the networking detail that causes the most mysterious failures in production EKS clusters. Every pod in EKS gets a real VPC IP address from your subnet CIDR. By default, VPC CNI pre-allocates IPs on each node — and you can run out.

The Default Behaviour (And Why It Fails)

Default VPC CNI behaviour:
Node warm pool = max(WARM_ENI_TARGET, WARM_IP_TARGET)
Default: WARM_ENI_TARGET = 1

On an m5.xlarge (3 ENIs, 15 IPs per ENI = 45 IPs max):
At startup: node pre-allocates 1 warm ENI = 15 IPs consumed
First pod scheduled: another ENI attached = 15 more IPs consumed
Result: 30 IPs consumed before you've scheduled any meaningful workloads

Fix — Enable Prefix Delegation

Prefix Delegation allocates a /28 block (16 IPs) per ENI slot instead of individual IPs. Same ENI count, 16× more IPs:

# Enable prefix delegation in VPC CNI addon config
resource "aws_eks_addon" "vpc_cni" {
  cluster_name  = aws_eks_cluster.main.name
  addon_name    = "vpc-cni"
  addon_version = "v1.18.1-eksbuild.1"

  configuration_values = jsonencode({
    env = {
      ENABLE_PREFIX_DELEGATION = "true"   # /28 prefixes instead of individual IPs
      WARM_PREFIX_TARGET       = "1"      # Keep 1 warm prefix per node
      MINIMUM_IP_TARGET        = "3"      # Minimum IPs always available
    }
  })
}
Why this matters for Ingress: Nginx Ingress pods, ALB controller pods, and your application pods all consume VPC IPs. On a cluster with 33 microservices and 2 replicas each plus Nginx, you need ~70+ pod IPs per AZ. Without prefix delegation, a subnet with 256 IPs (/24) fills up fast. With prefix delegation, the same number of ENIs supports 16× more pods.

6. Service Types — ClusterIP, NodePort, LoadBalancer Explained

Before Ingress makes sense, you need to understand Kubernetes Service types because Ingress sits on top of them:

# ClusterIP — internal only, no external access
# Default type — use for service-to-service communication
apiVersion: v1
kind: Service
metadata:
  name: fraud-scoring-internal
  namespace: fraud-detection
spec:
  type: ClusterIP
  selector:
    app: fraud-scoring
  ports:
    - port: 8080
      targetPort: 8080
# Accessible at: fraud-scoring-internal.fraud-detection.svc.cluster.local:8080
# NOT accessible from outside the cluster

---
# NodePort — exposes on every node's IP at a static port (30000-32767)
# Almost never the right choice in production EKS — use LoadBalancer or Ingress
apiVersion: v1
kind: Service
metadata:
  name: fraud-scoring-nodeport
spec:
  type: NodePort
  selector:
    app: fraud-scoring
  ports:
    - port: 8080
      targetPort: 8080
      nodePort: 30080   # Exposed on every node at this port
# Accessible at: <any-node-ip>:30080

---
# LoadBalancer — creates a cloud load balancer (NLB or ALB)
# One LB per service — gets expensive at scale
apiVersion: v1
kind: Service
metadata:
  name: fraud-scoring-lb
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "external"
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"
spec:
  type: LoadBalancer
  selector:
    app: fraud-scoring
  ports:
    - port: 443
      targetPort: 8080

The cost implications:

30 services × LoadBalancer type (NLB) = 30 NLBs × ~$16/month = $480/month
30 services × ClusterIP + 1 Nginx Ingress (NLB) = 1 NLB × ~$16/month = $16/month
Saving: $464/month — just from using Ingress instead of LoadBalancer per service

7. cert-manager — Automated TLS Certificate Management

Regardless of whether you use Nginx or ALB Ingress, you need TLS. For Nginx, cert-manager automates certificate issuance from Let's Encrypt or your internal CA. For ALB, ACM handles certificates natively — but cert-manager is still useful for internal services.

# cert-manager via Helm
resource "helm_release" "cert_manager" {
  name             = "cert-manager"
  repository       = "https://charts.jetstack.io"
  chart            = "cert-manager"
  version          = "v1.15.0"
  namespace        = "cert-manager"
  create_namespace = true

  set {
    name  = "installCRDs"
    value = "true"
  }

  # Pod Identity for Route 53 DNS validation
  set {
    name  = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
    value = aws_iam_role.cert_manager.arn
  }
}
# ClusterIssuer — Let's Encrypt with DNS-01 validation via Route 53
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: platform@company.com
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - dns01:
          route53:
            region: eu-west-1
            hostedZoneID: Z1234EXAMPLE   # Your Route 53 zone ID
---
# Certificate — automatically issued and renewed by cert-manager
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: fraud-api-tls
  namespace: fraud-detection
spec:
  secretName: fraud-api-tls    # Secret name referenced in Ingress
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - fraud-api.internal.company.com

8. The Rabobank Pattern — What We Actually Used

At Rabobank, the fraud detection platform used a hybrid approach that I'd replicate on any similar platform:

External access (developer tools, ArgoCD UI):
  → ALB (AWS LBC) with WAF WebACL + ACM certificate
  → Single ALB shared via IngressGroup annotation
  → WAF blocks known bad IPs, rate limits, OWASP rules

Application tier (33 microservices):
  → Nginx Ingress Controller with NLB in front
  → NLB has Elastic IPs — security team can allowlist the platform egress
  → Nginx handles path routing, canary weights, rate limiting via annotations
  → cert-manager issues internal TLS certs from internal CA

Message broker (Amazon MQ):
  → NLB in ip target mode, internal scheme
  → TLS passthrough — MQ handles its own TLS
  → Static private IP — consumers configured with fixed broker address

gRPC services (internal service-to-service):
  → ClusterIP only — no external exposure
  → CoreDNS for service discovery
  → No load balancer at all — Kubernetes handles routing via kube-proxy
The key insight: not every service needs a load balancer. Services that only communicate internally use ClusterIP — it's free, it's fast (no extra hop), and it scales automatically. Only services that need external access need an Ingress or LoadBalancer.

9. Cost Comparison — Real Numbers

Scenario: 30 microservices in EKS, eu-west-1

Option A — LoadBalancer per service (30 NLBs):
  30 NLBs × $16.43/month           = $492.90/month
  LCU charges (moderate traffic)    = ~$50.00/month
  Total:                            ~$542.90/month

Option B — ALB Ingress Controller (1 ALB per namespace × 5 namespaces):
  5 ALBs × $22.27/month             = $111.35/month
  LCU charges                       = ~$30.00/month
  Total:                            ~$141.35/month

Option C — ALB IngressGroup (1 shared ALB):
  1 ALB × $22.27/month              = $22.27/month
  LCU charges (higher per ALB)      = ~$40.00/month
  Total:                            ~$62.27/month

Option D — Nginx Ingress + 1 NLB (Rabobank pattern):
  1 NLB × $16.43/month              = $16.43/month
  2 Nginx pods (m5.large fraction)  = ~$15.00/month
  LCU on NLB (low — L4 only)        = ~$5.00/month
  Total:                            ~$36.43/month

Monthly saving: Option D vs Option A = $506.47/month
Annual saving:                       = $6,077.64/year

10. The Decision Framework

Do you need Layer 7 routing (path/host-based)?
├── NO → NLB (Service LoadBalancer type)
│   ├── Need static IP? → NLB + EIP
│   ├── gRPC / WebSocket / TCP? → NLB (TCP passthrough)
│   └── Ultra-low latency? → NLB
└── YES → Ingress (Layer 7)
    ├── How many services need external access?
    │   ├── 1-3 services → ALB Ingress Controller (simple, AWS-native)
    │   └── 4+ services → Nginx Ingress (cheaper, more control)
    ├── Need WAF integration?
    │   ├── YES → ALB Ingress Controller (native WAF annotation)
    │   └── NO → Either — Nginx with rate limiting, ALB without WAF
    ├── Need canary deployments?
    │   ├── YES → Nginx (native canary annotation)
    │   └── NO → Either
    ├── Need cross-namespace routing without multiple ALBs?
    │   ├── YES → Nginx
    │   └── NO (IngressGroup solves it) → ALB
    └── Need TLS managed by ACM?
        ├── YES → ALB (native ACM integration)
        └── NO (cert-manager is fine) → Nginx

Service-to-service communication (internal only)?
└── Always use ClusterIP — no load balancer needed

11. Common Mistakes & Anti-Patterns

Mistake 1: One LoadBalancer Service Per Microservice

Creating a Kubernetes Service of type LoadBalancer for each of your 30 microservices creates 30 NLBs. At ~$16/month each that's $480/month for what should be a $16/month NLB fronting Nginx. Use ClusterIP for internal services, Ingress for external.

Mistake 2: ALB in instance Target Mode Instead of ip

instance target mode routes to node ports, which adds an extra kube-proxy hop and breaks pod-level Security Groups. Always use ip target mode — it routes directly to pod ENIs, respects pod Security Groups, and has lower latency.

Mistake 3: Forgetting the Subnet Tags for ALB/NLB Discovery

The AWS Load Balancer Controller discovers subnets by tag. Without these tags, ALB/NLB creation silently fails with a confusing error:

# Public subnets — required for internet-facing ALB/NLB
resource "aws_subnet" "public" {
  tags = {
    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
    "kubernetes.io/role/elb"                    = "1"   # ← Required for internet-facing LB
  }
}

# Private subnets — required for internal ALB/NLB
resource "aws_subnet" "private" {
  tags = {
    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
    "kubernetes.io/role/internal-elb"           = "1"   # ← Required for internal LB
    "karpenter.sh/discovery"                    = var.cluster_name
  }
}

Mistake 4: No Connection Draining on Pod Termination

Without connection draining, a pod being terminated drops in-flight requests. The fix is two-part: deregistration delay on the target group (30 seconds) AND a preStop hook on the pod:

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 15"]   # Wait for LB to stop sending traffic
                                                # before the pod starts shutting down
terminationGracePeriodSeconds: 45              # Must be > deregistration delay + preStop sleep
Why this matters in production: A rolling deployment without connection draining drops in-flight requests every time a pod is replaced. In a fraud scoring service handling 200ms real-time transactions, each dropped connection is a failed score — which may mean a transaction is either incorrectly approved or blocked. The preStop sleep hook and deregistration delay must both be configured, and terminationGracePeriodSeconds must exceed their sum. Test this with a load test during a rolling deployment before go-live.

Mistake 5: Using Nginx Without Resource Requests

Nginx Ingress pods without CPU/memory requests will be scheduled on any available node and can be evicted by Karpenter during bin-packing. Set resource requests that reflect actual usage, and use topology spread constraints to keep one Nginx pod per AZ:

resources:
  requests:
    cpu: "200m"
    memory: "256Mi"
  limits:
    cpu: "1000m"
    memory: "1Gi"

Mistake 6: Not Enabling Access Logs

ALB and NLB access logs are disabled by default. For any production workload, enable them — they're essential for debugging latency spikes, error rates, and security incidents. The S3 storage cost is negligible.

Mistake 7: Mixing Ingress Classes Without Isolation

Running both Nginx and ALB Ingress Controller on the same cluster without explicit ingressClassName on every Ingress resource causes both controllers to pick up every Ingress object and fight over it. Always set ingressClassName explicitly:

spec:
  ingressClassName: nginx    # or "alb" — always explicit, never rely on default
Why this matters in production: Missing subnet tags (kubernetes.io/role/elb for public, kubernetes.io/role/internal-elb for private) cause ALB and NLB creation to fail silently. The AWS Load Balancer Controller logs an error but the Kubernetes Service or Ingress object shows no clear failure message — it just never gets an external IP. This is the most common reason for "my LoadBalancer service has been pending for 20 minutes" questions. Tag your subnets in Terraform before any EKS workload needs external access.

Architecture Decision Matrix

Requirement Nginx + NLB ALB Ingress NLB (LoadBalancer)
Path/host routing ✅ Native ✅ Native ❌ Layer 4 only
WAF integration ❌ Manual ✅ Native annotation ❌ Not supported
Cross-namespace routing ✅ Native ⚠️ IngressGroup partial ❌ N/A
Canary deployments ✅ Native annotation ⚠️ Complex ❌ Not supported
gRPC ✅ TCP passthrough ⚠️ HTTP/2 required ✅ TCP passthrough
Static IP ✅ Via NLB EIP ❌ DNS only ✅ EIP native
TLS via ACM ❌ cert-manager ✅ Native ✅ Native
Cost at scale (30+ services) ✅ Lowest ⚠️ IngressGroup helps ❌ Highest
AWS-native management ⚠️ Additional component ✅ Fully managed ✅ Fully managed
Latency overhead ⚠️ Extra Nginx hop ✅ Direct to pod ✅ Lowest latency
Rate limiting ✅ Native annotation ❌ Needs WAF ❌ Not supported
Custom headers / CORS ✅ Native ❌ Needs Lambda ❌ N/A

The Golden Rule

"Use ClusterIP for everything that doesn't need external access — it's free, fast, and scales automatically. Use Nginx Ingress when you have multiple services, need cross-namespace routing, canary deployments, or rate limiting. Use ALB Ingress when you need WAF integration, ACM certificate management, or a fully AWS-managed Layer 7 load balancer for a small number of services. Use NLB when you need gRPC, static IPs, TCP/UDP protocols, or ultra-low latency. And always use ip target mode — routing to pod IPs directly is faster, more secure, and enables pod-level Security Groups."

Tags: #AWS #EKS #Kubernetes #Ingress #ALB #NLB #Nginx #Networking #DevSecOps #Terraform #Helm #LoadBalancing #CloudArchitecture #VPC

Ankush Panday

Specializing in highly scalable AWS infrastructure and automated quality engineering.

Connect on LinkedIn