Terraform Lesson 51 of 89

Terraform on AWS: Application & Network Load Balancers, Target Groups, Listeners & Health Checks

In a nutshell

Picture the front desk of a busy office tower. An Application Load Balancer (ALB) is the smart receptionist: it opens every request, reads it, and routes on what it says — this caller wants /api/*, send them to the API team; that one asked for admin.example.com, send them upstairs; this is plain HTTP, bounce them to the secure line first. Reading the request is what lets it route by path, host and header, terminate TLS, and turn away attackers with a WAF — and that reading is exactly why it works at layer 7 (HTTP).

A Network Load Balancer (NLB) is the fast switchboard: it never opens the envelope, it just forwards by port — 443 to that pool, 5432 to this one — as fast as the wire allows. It can’t route by URL because it never reads one, but in exchange it gives you static IP addresses, preserves the caller’s real address, and moves raw TCP/UDP at a scale and latency the receptionist can’t match. It works at layer 4 (TCP/UDP).

Both hand their calls to target groups — the teams the calls get routed to. A target group is a pool of backends (EC2 instances, IPs, or a Lambda) plus a health check that keeps knocking — “still alive? still alive?” — and quietly pulls any member that stops answering out of rotation. When people say “the load balancer is down,” they almost always mean “every target is failing its health check and the ALB has nothing healthy left to forward to” — which is why a 503 in this lesson is nearly always a health-check story, not a load-balancer story.

Level: Senior · Time: ~51 min

Prerequisites: core Terraform (HCL, providers, resources, variables, count/for_each, state and modules) from the Foundation tier; an AWS account with credentials configured (aws configure, SSO, or an assumed role); the AWS networking basics — VPC, subnets, route tables and an internet gateway — from the VPC, Subnets, IGW & NAT lesson; and a Route 53 public hosted zone you own for the ACM validation and the alias record.

After this lesson you can:

Every public workload on AWS eventually needs something in front of it — something that owns a stable DNS name, spreads traffic across more than one instance in more than one Availability Zone, notices when an instance goes bad and routes around it, and (if it speaks HTTP) terminates TLS and decides where to send /api/* versus /*. AWS gives you a family of load balancers for that job, and picking the wrong member is one of the most expensive architecture mistakes a team makes: the Application Load Balancer (ALB) is a layer-7 reverse proxy that reads the URL, terminates TLS from ACM, and routes on host, path and header; the Network Load Balancer (NLB) is a layer-4 forwarder that never opens the packet, gives you static IPs and the lowest possible latency; and the old Classic Load Balancer (CLB) is the legacy box you should be migrating off. Clicking these together in the console is slow, undocumented, and impossible to reproduce across environments. This lesson builds the whole tier in Terraform, the way you would run it in production.

By the end you will have stood up, from an empty directory, a real internet-facing ALB spanning two public subnets, with an HTTPS listener whose certificate is a public ACM certificate DNS-validated through Route 53, a companion HTTP:80 listener that 301-redirects to HTTPS, a target group with a health check, path-based listener rules, and two EC2 instances running a web server — then curled its DNS name to watch it spread requests across both instances, read target health from the CLI, and torn it all down with terraform destroy. You will also learn exactly when to reach for an NLB instead (static IPs, TCP/UDP, TLS pass-through, extreme throughput), how an Auto Scaling Group registers into a target group instead of a static attachment, how to ship access logs to S3 and attach a WAF, and how cross-zone load balancing differs between ALB and NLB. Above all you will leave able to diagnose the error that every first ALB throws at you: 503 Service Unavailable, whose real cause is almost never the load balancer and almost always a failing health check, a blocking security group, an unattached target, or a cert that never validated.

This is the provider-specific, hands-on layer of the course. It assumes you already know core Terraform — HCL, providers, resources, variables, state and modules from the Foundation and Intermediate tiers — and applies it to a real cloud, relentlessly, with copy-pasteable .tf files and a terraform init → plan → apply → verify → destroy you actually run.

What you’ll build

The scenario is the one you meet on day one of almost any AWS project: a web application that must be reachable on a public HTTPS endpoint, survive a single instance or a whole AZ dying, offload TLS at the edge, route /api/* to a different backend than /*, and bounce plain HTTP up to HTTPS. That is an Application Load Balancer job end to end. Behind it sits a target group of two EC2 instances running Apache, each in a different Availability Zone, reachable only from the load balancer’s security group. The ALB spans two public subnets in two AZs (an ALB legally requires at least two AZs), owns a DNS name you alias from Route 53, terminates TLS using a certificate issued and auto-renewed by AWS Certificate Manager (ACM), and only forwards a request to an instance that is currently passing a health check.

Alongside it we cover the layer-4 story, because half the time an L7 proxy is the wrong answer. A Network Load Balancer is what you put in front of raw TCP or UDP — a database read-replica pool, a game server fleet, an MQTT broker, a gRPC service that needs client-IP visibility, or any case where you want static IPs and the lowest latency and don’t need to read the request. And we place both against the Classic Load Balancer so you can recognise — and retire — the legacy box when you inherit it.

Why Terraform rather than the console, the AWS CLI, or CloudFormation? Because this tier is a graph of a dozen tightly-coupled resources — a load balancer, a target group, two listeners, listener rules, target attachments, an ACM certificate, the Route 53 validation records, the alias record, two security groups, two instances — and every one of them has an ARN or ID that another one references. Terraform’s dependency graph wires those references for you, plans the exact diff before touching anything, waits for the certificate to validate before creating the listener, and lets you stamp the identical stack into dev, staging and prod from one module with different variables. The console gives you none of that; CloudFormation gives you the graph but not the multi-cloud state model, the plan preview, or the for_each ergonomics you already know.

Terraform-built AWS L7 edge — a client resolves a Route 53 alias to an internet-facing Application Load Balancer across two AZs; the HTTPS listener terminates TLS with an ACM certificate, a companion HTTP:80 listener 301-redirects to HTTPS, listener rules route by path and host to a target group, and a health check forwards only to EC2 instances that are currently healthy; the L4 Network Load Balancer is the raw-TCP alternative

Reading that diagram left to right is reading the request path you are about to build: Terraform provisions the graph, the client resolves the Route 53 alias, hits the ALB on 443, the HTTPS listener terminates TLS with the ACM cert, the HTTP:80 listener would have redirected any plain-HTTP caller up to HTTPS first, the listener rules select a target group by path or host, and the health check guarantees the chosen instance is alive before the ALB forwards. Badge 1 marks the ALB-vs-NLB layer decision; badge 6 marks the 503 you’ll learn to diagnose.

Here is the full inventory of what a single terraform apply will create, and roughly what each part costs if you leave it running (Mumbai / ap-south-1, on-demand, indicative July 2026):

Resource (Terraform) AWS object Role in the build Rough cost if left up
aws_vpc + aws_internet_gateway VPC + IGW The network + internet edge Free
aws_subnet (public) + route table Two public subnets, 2 AZs ALB needs ≥2 AZs Free
aws_security_group ×2 ALB SG, web SG ALB open to world; web open only to ALB Free
aws_lb (application) Application Load Balancer The L7 edge ~₹1,400/mo + LCU (~$16+)
aws_lb_target_group Target group Health-gated instance set Free
aws_lb_listener ×2 HTTP:80 redirect, HTTPS:443 Entry points Free
aws_lb_target_group_attachment ×2 Target registrations Attach the two EC2s Free
aws_instance ×2 (t3.micro) 2× EC2 + Apache The target group members ~₹1,500/mo total (~$18)
aws_acm_certificate (+ validation) ACM public cert Listener TLS certificate Free (public certs)
aws_route53_record ×N Validation CNAMEs + alias DNS validation + app.<domain> ~₹0 (per-query pennies)

The ALB itself is the line item that bills continuously — an hourly charge plus Load Balancer Capacity Units (LCUs) — so this is emphatically a build it, verify it, destroy it lesson, not one to leave running overnight. ACM public certificates are free; you pay only for what they front. Every costly or destructive step below is marked ⚠️.

Where this fits: the target group members here are deliberately thin so the lesson stays about load balancing. The instances, their security groups and key pairs are the subject of the AWS security groups, EC2 & key pairs lesson — we create minimal ones inline. Turning those two static instances into a self-registering, self-healing fleet is the AWS Auto Scaling & launch templates lesson, which attaches to the very target group we build here. And the ACM certificate plus Route 53 hosted zone the listener depends on are built properly in the Route 53, ACM, DNS & SSL lesson; here we consume an existing hosted zone and issue one cert inline.

The ELB family: Classic vs ALB vs NLB vs GWLB

AWS’s “Elastic Load Balancing” (ELB) service is not one product but four generations of load balancer sharing an umbrella. The single decision that governs this whole tier is which one you pick, and that turns almost entirely on which layer of the network stack you balance at and what features you need. Get this right and everything downstream follows; get it wrong and you spend a quarter migrating.

Load balancer OSI layer Terraform Protocols Routing on TLS termination Static IP WAF Status
Classic (CLB) L4 + L7 (basic) aws_elb HTTP/S, TCP, SSL Port only Yes (basic) No No Legacy — avoid
Application (ALB) L7 (HTTP) aws_lb (application) HTTP, HTTPS, gRPC, WebSocket Host, path, header, method, query, source-IP Yes (from ACM) No (has DNS name) Yes Current
Network (NLB) L4 (TCP/UDP) aws_lb (network) TCP, UDP, TLS Port + protocol only Yes (TLS listener) Yes (per-AZ EIP) No Current
Gateway (GWLB) L3 (IP) aws_lb (gateway) IP (GENEVE :6081) Transparent — bump-in-wire No N/A N/A Current (niche)

Two things jump out. First, the ALB, NLB and GWLB are all the same Terraform resourceaws_lb — switched by one argument, load_balancer_type. That is a mercy: the target group, listener and attachment resources are shared vocabulary across them. The Classic Load Balancer is a different, older resource (aws_elb) with an all-in-one shape and no separate target groups; you should only be touching it to import and then migrate off. Second, the layer dictates the feature set: only the L7 ALB can read a URL, so only the ALB can do path/host routing, HTTP redirects, and carry an AWS WAF; only the L4 NLB gives you static/Elastic IPs and preserves the client source IP by default.

The Gateway Load Balancer (GWLB) is the odd one out and appears here only so the family is complete: it operates at L3, transparently steering all IP traffic through a fleet of third-party virtual appliances (firewalls, IDS/IPS, deep-packet inspection) using the GENEVE protocol on port 6081, then handing the traffic back. You reach for it when a security vendor’s appliance must sit “bump-in-the-wire” for an entire VPC’s traffic — a centralised inspection pattern, not a web front door. It is not something a typical application team provisions, and we won’t build one; know it exists so you recognise a load_balancer_type = "gateway" when you see it.

That leaves the real day-to-day decision — ALB or NLB — and it comes down to whether you need to read the request:

Requirement Choose ALB Choose NLB
Route on URL path / hostname / header Yes No (can’t see them)
HTTP→HTTPS redirect, fixed responses Yes No
Terminate TLS from an ACM cert Yes Yes (TLS listener)
Pass TLS through to the target No Yes (TCP listener)
Static / Elastic IP addresses No (DNS name only) Yes
Preserve original client source IP Via X-Forwarded-For header Yes, natively (default)
UDP or raw TCP (databases, gaming, VoIP) No (HTTP family only) Yes
Attach an AWS WAF Yes No
Millions of req/s, ultra-low latency Good Best (pass-through)
PrivateLink endpoint service front No Yes (NLB required)

The rule of thumb writes itself: if the traffic is HTTP/S and you need to route on URL, redirect, or run a WAF, use an ALB. If it is raw TCP/UDP, or you need static IPs, client-IP preservation without a header, TLS pass-through, PrivateLink, or the absolute lowest latency, use an NLB. A great many designs use both: an ALB for the public web tier, an internal NLB deep inside the VPC fronting a database or a gRPC service, and sometimes an ALB as a target of an NLB when you need a static IP in front of L7 routing.

The ALB in Terraform: aws_lb, target groups, listeners and rules

Start with the ALB, because it is what most teams need and it teaches the vocabulary — load balancer, target group, listener, rule, health check — that the NLB reuses at L4. A working ALB is never one resource; it is a small graph of four resource types wired by ARN.

aws_lb — the load balancer itself

The aws_lb resource is almost empty on its own. For an ALB you set load_balancer_type = "application", hand it security groups (an ALB has SGs; an NLB traditionally does not) and a set of subnets across at least two AZs. internal = false makes it internet-facing (it gets a public DNS name); internal = true makes it private (only reachable inside the VPC).

resource "aws_lb" "web" {
  name               = "kv-web-alb"
  load_balancer_type = "application"
  internal           = false                       # internet-facing
  security_groups    = [aws_security_group.alb.id]  # ALBs have SGs
  subnets            = aws_subnet.public[*].id      # ≥2 subnets, ≥2 AZs

  idle_timeout               = 60      # seconds an idle connection is held
  enable_deletion_protection = false   # set true in prod
  drop_invalid_header_fields = true    # security hardening
  enable_http2               = true

  tags = { Name = "kv-web-alb" }
}

The arguments you actually reach for, and what each governs:

Argument What it controls Default / note
load_balancer_type application / network / gateway application
internal Public (false) vs private (true) false
security_groups SGs on the ALB (ALB only) Required for ALB
subnets / subnet_mapping AZ coverage; subnet_mapping allows a static EIP (NLB) ≥2 AZs required
idle_timeout Seconds an idle client connection is kept (ALB) 60
enable_deletion_protection Blocks terraform destroy / console delete false
drop_invalid_header_fields Drop malformed HTTP headers false (set true)
enable_cross_zone_load_balancing Spread across AZs (see cross-zone section) ALB always on; NLB false
access_logs Ship request logs to S3 (block) Off
preserve_host_header Pass the client Host header unchanged false

The internal vs internet-facing choice is one attribute, but it has real consequences worth stating plainly:

internal = false (internet-facing) internal = true (internal)
DNS name resolves to Public IPs Private VPC IPs
Subnets required Public (route to IGW) Private (or public)
Reachable from The internet Inside the VPC / peered / VPN
Typical use Public web front door Service-to-service, internal APIs

An internet-facing ALB must live in public subnets — subnets whose route table has a default route to an Internet Gateway — or it will provision but never be reachable. That subnet-AZ-coverage rule is one of the most common first-build failures, and it’s why the demo builds an explicit VPC with two public subnets.

aws_lb_target_group — where the traffic lands, and the health check

A target group is the pool of things the ALB forwards to, plus the health check that decides which members are eligible. It is decoupled from the load balancer on purpose: you can move a target group between listeners, share it across rules, and — crucially — an Auto Scaling Group registers itself into a target group, not into the ALB directly.

resource "aws_lb_target_group" "web" {
  name        = "kv-web-tg"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = aws_vpc.this.id
  target_type = "instance"     # instance | ip | lambda | alb

  deregistration_delay = 30    # connection-draining seconds (default 300)

  health_check {
    enabled             = true
    protocol            = "HTTP"
    path                = "/"
    port                = "traffic-port"  # same port the target serves on
    matcher             = "200"           # "200-299", "200,302" all valid
    interval            = 15              # seconds between checks
    timeout             = 5               # seconds to wait for a response
    healthy_threshold   = 3               # checks to mark healthy
    unhealthy_threshold = 3               # checks to mark unhealthy
  }

  stickiness {
    enabled = false
    type    = "lb_cookie"
    cookie_duration = 86400
  }

  tags = { Name = "kv-web-tg" }
}

The target_type determines what you can attach, and it is a decision you cannot change later without replacing the target group:

target_type You attach Registered by When to use
instance EC2 instance IDs ALB routes to the instance’s primary IP Classic EC2 / ASG behind the LB
ip IP addresses (VPC, peered, on-prem) You register CIDRs / ENIs Containers (awsvpc), on-prem, cross-VPC
lambda A Lambda function ARN The ALB invokes the function Serverless behind an ALB
alb An ALB ARN An NLB forwards to an ALB Static IP in front of L7 routing

The health check is the single most important block in this whole lesson, because a health check with the wrong path or port silently marks every target unhealthy and the ALB then answers 503 — the failure mode you will spend the most time on. Every field earns its keep:

Health-check field Meaning Sensible value
protocol HTTP / HTTPS / TCP (NLB) Match the target’s protocol
path URL the check requests (HTTP/S) A cheap, dependency-free /healthz
port Port to probe; traffic-port = the serving port traffic-port
matcher HTTP status(es) counted as healthy 200 (or 200-399)
interval Seconds between checks 15–30
timeout Seconds to wait per check 5 (< interval)
healthy_threshold Consecutive passes to mark healthy 2–3
unhealthy_threshold Consecutive fails to mark unhealthy 2–3

Two more target-group behaviours matter in production. deregistration_delay (connection draining) is how long the ALB keeps sending in-flight requests to a target you’ve begun removing — 300s by default, which feels forever during a deploy; drop it to 30–60s for fast web apps, keep it high for long-lived connections. And stickiness binds a client to one target so session state on the instance survives — three flavours:

Stickiness type How it pins Use when
lb_cookie (ALB) ALB-generated cookie, duration you set Generic session affinity, no app change
app_cookie (ALB) Your application’s own cookie name You already emit a session cookie
source_ip (NLB) 5-tuple / source IP hash L4 flows that must land on one target

Prefer stateless apps and no stickiness where you can — stickiness defeats even load distribution and turns one hot client into one hot instance. Use it only when session state genuinely lives on the target.

One last target-group knob matters for modern backends: protocol_version tells the ALB how to speak to the target — plain HTTP/1.1, HTTP/2, or gRPC. Get it wrong and a gRPC service answers every request with a 502:

protocol_version ALB → target speaks Use for
HTTP1 HTTP/1.1 Standard web apps (default)
HTTP2 HTTP/2 (h2c) HTTP/2 backends
GRPC gRPC over HTTP/2 gRPC services (health-check on gRPC status codes)

aws_lb_listener — the front door (redirect + ACM + forward)

A listener binds a port and protocol on the load balancer to a default action. A production ALB has two: an HTTP:80 listener whose only job is to redirect to HTTPS, and an HTTPS:443 listener that terminates TLS with an ACM certificate and forwards to the target group.

# HTTP :80 — redirect everything to HTTPS, never serve cleartext
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"   # permanent
    }
  }
}

# HTTPS :443 — terminate TLS with the ACM cert, forward to the target group
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.web.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.app.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.web.arn
  }
}

Two details make this correct rather than merely plausible. The certificate_arn references aws_acm_certificate_validation.app.certificate_arn, not aws_acm_certificate.app.arn — pointing at the validation resource forces Terraform to wait until the certificate is actually ISSUED before it tries to create the listener, which avoids a “certificate not found” race. And the ssl_policy names a predefined TLS/cipher policy — the security floor for the handshake:

default_action.type What it does Key sub-block
forward Send to one or more target groups target_group_arn or forward{} (weighted)
redirect 301/302 to another URL/scheme/port redirect{}
fixed-response Return a canned status + body (e.g. 404) fixed_response{}
authenticate-oidc OIDC login before forwarding authenticate_oidc{}
authenticate-cognito Cognito login before forwarding authenticate_cognito{}
ssl_policy (common) TLS floor Use for
ELBSecurityPolicy-TLS13-1-2-2021-06 TLS 1.2 + 1.3 Recommended default
ELBSecurityPolicy-TLS13-1-2-Res-2021-06 1.2 + 1.3, restricted ciphers Stricter compliance
ELBSecurityPolicy-FS-1-2-Res-2020-10 Forward-secrecy only FS mandates
ELBSecurityPolicy-2016-08 TLS 1.0+ (legacy) Only for old clients

You can also attach extra certificates to one HTTPS listener for SNI (many hostnames on one ALB) with aws_lb_listener_certificate — the listener picks the right cert per SNI hostname automatically.

aws_lb_listener_rule — path/host routing and weighted blue-green

The listener’s default_action is the catch-all. Listener rules add conditions — path, host, header, HTTP method, query string, source IP — each with a priority (lower numbers evaluated first) that steer matching requests to a different target group. This is how one ALB fronts many services.

# Route /api/* to the API target group; everything else hits the default.
resource "aws_lb_listener_rule" "api" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 100                       # unique; lower = first

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.api.arn
  }
  condition {
    path_pattern { values = ["/api/*"] }
  }
}

# Host-based: admin.<domain> to the admin target group.
resource "aws_lb_listener_rule" "admin" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 200
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.admin.arn
  }
  condition {
    host_header { values = ["admin.kloudvin.dev"] }
  }
}

For blue-green or canary deploys, a single forward action can split traffic across two target groups by weight — shift 10% to green, watch, then flip to 100%:

  action {
    type = "forward"
    forward {
      target_group {
        arn    = aws_lb_target_group.blue.arn
        weight = 90
      }
      target_group {
        arn    = aws_lb_target_group.green.arn
        weight = 10
      }
      stickiness {
        enabled  = true
        duration = 300
      }
    }
  }
Rule condition Matches on Example
path_pattern URL path /api/*, /static/*
host_header Host: header admin.example.com
http_header Any request header X-Env: canary
http_request_method Verb POST, PUT
query_string Query params ?version=beta
source_ip Client CIDR 203.0.113.0/24

Priorities must be unique per listener and are evaluated ascending; the first match wins, and the default action catches anything unmatched. A classic bug is two rules with the same priority (an apply error) or a broad /* rule with a lower number shadowing a specific one.

Attaching targets — static vs an Auto Scaling Group

Finally, something must actually be in the target group. For a fixed set of instances you use aws_lb_target_group_attachment, one per target:

resource "aws_lb_target_group_attachment" "web" {
  count            = var.instance_count
  target_group_arn = aws_lb_target_group.web.arn
  target_id        = aws_instance.web[count.index].id
  port             = 80
}

But in production you almost never attach instances by hand — an Auto Scaling Group registers every instance it launches into the target group automatically via its target_group_arns, and deregisters them on scale-in. You attach the group once, and membership becomes dynamic:

resource "aws_autoscaling_group" "web" {
  # ... launch template, min/max/desired, vpc_zone_identifier ...
  target_group_arns = [aws_lb_target_group.web.arn]  # self-registers
  health_check_type = "ELB"                          # honour TG health
}
Attach method Terraform Membership Use when
Static attachment aws_lb_target_group_attachment Fixed, per-instance A known, small set of instances
ASG registration target_group_arns on the ASG Dynamic, scales with fleet Production — any autoscaled tier
Post-hoc ASG attach aws_autoscaling_attachment Dynamic Wiring an existing ASG to a new TG

Setting the ASG’s health_check_type = "ELB" is the important half: now the ASG trusts the target group’s health check, so an instance that fails the ALB check is not just pulled from rotation — it’s terminated and replaced. That closes the self-healing loop, and it’s why the Auto Scaling & launch templates lesson attaches to exactly this target group.

The NLB in Terraform: L4, static IPs, TCP/UDP/TLS and client-IP preservation

When the traffic isn’t HTTP — or when you need a static IP, client-IP preservation without a header, TLS pass-through, UDP, PrivateLink, or simply the lowest latency at the highest throughput — you swap the ALB for a Network Load Balancer. It’s the same aws_lb resource with load_balancer_type = "network", and the same target-group and listener resources, but the shape shifts to L4.

resource "aws_lb" "nlb" {
  name               = "kv-nlb"
  load_balancer_type = "network"
  internal           = false

  # subnet_mapping gives each AZ a static Elastic IP — NLB's superpower.
  subnet_mapping {
    subnet_id     = aws_subnet.public[0].id
    allocation_id = aws_eip.nlb_a.id
  }
  subnet_mapping {
    subnet_id     = aws_subnet.public[1].id
    allocation_id = aws_eip.nlb_b.id
  }

  enable_cross_zone_load_balancing = true   # NLB default is FALSE — opt in
}

resource "aws_lb_target_group" "tcp" {
  name        = "kv-tcp-tg"
  port        = 443
  protocol    = "TCP"                 # or UDP, TLS, TCP_UDP
  vpc_id      = aws_vpc.this.id
  target_type = "instance"

  # Preserve the real client source IP to the target (default true for
  # instance/ip TCP targets; the target sees the client, not the NLB).
  preserve_client_ip = true

  health_check {
    protocol = "TCP"                  # or HTTP for a richer check
    port     = "traffic-port"
    interval = 10
  }
}

resource "aws_lb_listener" "tcp" {
  load_balancer_arn = aws_lb.nlb.arn
  port              = 443
  protocol          = "TCP"           # TCP passthrough — no TLS termination
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tcp.arn
  }
}

The behaviours that make the NLB a different animal, not just a cheaper ALB:

NLB trait Behaviour Why it matters
Static / Elastic IPs One EIP per AZ via subnet_mapping Allowlisting, DNS pinning, firewalls that want IPs
Client-IP preservation Target sees the real client IP (default on) No X-Forwarded-For parsing needed
Protocols TCP, UDP, TLS, TCP_UDP Databases, DNS, gaming, VoIP, IoT
TLS termination or pass-through TLS listener terminates; TCP passes through End-to-end encryption to the target if you want it
No security group (historically) SGs now supported but optional Control access at the target SG
Cross-zone LB Off by default, and billed when on Cost/latency trade-off (see below)
Idle timeout 350s for TCP, fixed Long-lived flows reset silently past it
PrivateLink An NLB backs a VPC endpoint service Only the NLB can front PrivateLink

An NLB listener speaks one of four L4 protocols, and whether it terminates TLS or passes it straight through is the sub-decision that trips people up:

NLB listener protocol TLS behaviour Use for
TCP Pass-through — the target terminates TLS End-to-end encryption, any TCP app
TLS NLB terminates TLS with an ACM cert Offload TLS at the load balancer
UDP Raw UDP DNS, gaming, VoIP, IoT telemetry
TCP_UDP Both protocols on one port Services that use both (e.g. DNS)

The two traps that bite people: the 350-second TCP idle timeout silently drops long-lived connections (gRPC streams, DB sessions, SSH) that go quiet — enable TCP keepalives below 350s on both ends. And cross-zone load balancing is off by default on an NLB (it’s always on and free on an ALB), so without enable_cross_zone_load_balancing = true an NLB only sends a client to targets in the same AZ the client landed in — which can look like uneven load or dead targets if one AZ is thin.

Access logs, WAF and cross-zone load balancing

Three cross-cutting concerns finish the picture: where the logs go, how you screen hostile traffic, and how traffic spreads across AZs.

Access logs to S3. An ALB can write a line per request to S3 — invaluable for debugging a 5xx after the fact and for security forensics. It’s an access_logs block on the aws_lb, plus an S3 bucket whose policy lets ELB write to it. The bucket policy is the fiddly part: in most regions you grant the regional ELB service account (data.aws_elb_service_account) s3:PutObject on the log prefix; newer regions use the logdelivery.elasticloadbalancing.amazonaws.com service principal instead.

data "aws_elb_service_account" "main" {}
data "aws_caller_identity"    "current" {}

resource "aws_s3_bucket" "alb_logs" {
  bucket        = "kv-alb-logs-2026"
  force_destroy = true
}

resource "aws_s3_bucket_policy" "alb_logs" {
  bucket = aws_s3_bucket.alb_logs.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { AWS = data.aws_elb_service_account.main.arn }
      Action    = "s3:PutObject"
      Resource  = "${aws_s3_bucket.alb_logs.arn}/alb/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
    }]
  })
}

# then on the ALB, add the block:
#   access_logs {
#     bucket  = aws_s3_bucket.alb_logs.bucket
#     prefix  = "alb"
#     enabled = true
#   }

WAF association. An AWS WAF (WAFv2) web ACL — managed rule groups (OWASP top 10, bot control), rate limits, geo-match, IP allow/deny — attaches to an ALB with one resource. Note WAF only associates with L7 front ends (ALB, API Gateway, CloudFront, AppSync) — never an NLB, because the NLB can’t read L7.

resource "aws_wafv2_web_acl_association" "alb" {
  resource_arn = aws_lb.web.arn
  web_acl_arn  = aws_wafv2_web_acl.this.arn
}
Front end AWS WAF attachable? Why
ALB Yes (aws_wafv2_web_acl_association) Reads L7
API Gateway / AppSync Yes L7
CloudFront Yes (global scope WAF) L7 edge
NLB No L4 — can’t inspect HTTP

Cross-zone load balancing. This governs whether a load balancer node in AZ-a can send to targets in AZ-b. The behaviour — and the bill — differ by type:

ALB NLB
Default Always on Off
Configurable No (always on) Yes, per-LB or per-target-group
Inter-AZ data charge Free Charged when enabled
Effect off N/A Client only reaches same-AZ targets
Terraform n/a enable_cross_zone_load_balancing

The practical upshot: on an ALB you never think about it. On an NLB, leave it off and one thin AZ starves; turn it on and you get even distribution but pay inter-AZ transfer — a deliberate cost/resilience trade-off.

Hands-on: build it with Terraform

⚠️ This provisions real, billable AWS resources — an Application Load Balancer (hourly + LCU) and two EC2 instances. It also requires a Route 53 public hosted zone you own (for the ACM DNS validation and the alias record). Follow it end to end, verify, then run the destroy step. Do not leave it up.

We now assemble everything above into one working project: an internet-facing ALB across two public subnets, an HTTPS listener whose cert is a DNS-validated ACM certificate, an HTTP→HTTPS redirect, a target group with a health check, and two Apache instances. Lay out the files:

mkdir -p alb-demo && cd alb-demo
touch versions.tf provider.tf variables.tf network.tf \
      security.tf compute.tf acm.tf alb.tf outputs.tf

1. Pin Terraform and the provider (versions.tf). Pin aws with ~> so a plan in CI never silently changes behaviour, and use a remote backend — for AWS that is S3 for state plus a DynamoDB table for locking (newer provider/state versions also support S3-native locking via use_lockfile):

# versions.tf
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }

  # Remote state (AWS = S3 + DynamoDB lock). Create these once, out of band.
  backend "s3" {
    bucket         = "kv-tfstate-2026"
    key            = "alb-demo/terraform.tfstate"
    region         = "ap-south-1"
    dynamodb_table = "kv-tf-locks"
    encrypt        = true
  }
}

2. Configure the provider (provider.tf). Authenticate ahead of time with aws configure / SSO / an assumed role — never put keys in HCL. default_tags stamps every resource for cost attribution:

# provider.tf
provider "aws" {
  region = var.region
  default_tags {
    tags = { project = "tf-course", lesson = "alb-nlb", owner = "vinod" }
  }
}

3. Variables (variables.tf). Parameterise region, sizes, instance count and — because we DNS-validate a real cert — the hosted zone and the app hostname:

# variables.tf
variable "region" {
  type    = string
  default = "ap-south-1"
}
variable "prefix" {
  type    = string
  default = "kv-web"
}
variable "instance_type" {
  type    = string
  default = "t3.micro"
}
variable "instance_count" {
  type    = number
  default = 2
}

variable "hosted_zone_name" {
  type        = string
  description = "Existing public Route 53 zone, trailing dot, e.g. kloudvin.dev."
}
variable "app_fqdn" {
  type        = string
  description = "Hostname to serve, e.g. app.kloudvin.dev"
}

4. Network (network.tf). An internet-facing ALB needs two public subnets in two AZs. We build a minimal VPC, an internet gateway, two public subnets picked from the region’s AZs, and a route table that sends 0.0.0.0/0 to the IGW:

# network.tf
data "aws_availability_zones" "available" { state = "available" }

resource "aws_vpc" "this" {
  cidr_block           = "10.30.0.0/16"
  enable_dns_hostnames = true
  tags                 = { Name = "${var.prefix}-vpc" }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.this.id
  cidr_block              = cidrsubnet(aws_vpc.this.cidr_block, 8, count.index)
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true
  tags = { Name = "${var.prefix}-public-${count.index}" }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

5. Security groups (security.tf). Two SGs enforce the core rule: the world reaches the ALB; only the ALB reaches the instances. The web SG’s ingress references the ALB SG by ID, not a CIDR — the correct, self-documenting way:

# security.tf
resource "aws_security_group" "alb" {
  name_prefix = "${var.prefix}-alb-"
  vpc_id      = aws_vpc.this.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "web" {
  name_prefix = "${var.prefix}-web-"
  vpc_id      = aws_vpc.this.id

  ingress {                         # only the ALB, only on :80
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

6. The two web instances (compute.tf). Amazon Linux 2023 via a data-source AMI lookup, one per AZ, each running a tiny Apache page that names its own host so we can see load balancing when we curl:

# compute.tf
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
  filter {
    name   = "architecture"
    values = ["x86_64"]
  }
}

locals {
  user_data = base64encode(<<-EOT
    #!/bin/bash
    dnf install -y httpd
    echo "Hello from $(hostname -f) — behind the ALB" > /var/www/html/index.html
    systemctl enable --now httpd
  EOT
  )
}

resource "aws_instance" "web" {
  count                  = var.instance_count
  ami                    = data.aws_ami.al2023.id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public[count.index % 2].id
  vpc_security_group_ids = [aws_security_group.web.id]
  user_data_base64       = local.user_data

  tags = { Name = "${var.prefix}-${count.index}" }
}

7. The ACM certificate, DNS-validated via Route 53 (acm.tf). This is the part that ties in Route 53: we request a public cert for app_fqdn, let ACM emit the validation CNAMEs, write them into the hosted zone with for_each, and block on aws_acm_certificate_validation so nothing downstream runs before the cert is ISSUED:

# acm.tf
data "aws_route53_zone" "this" {
  name         = var.hosted_zone_name
  private_zone = false
}

resource "aws_acm_certificate" "app" {
  domain_name       = var.app_fqdn
  validation_method = "DNS"
  lifecycle { create_before_destroy = true }
}

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.app.domain_validation_options :
    dvo.domain_name => {
      name   = dvo.resource_record_name
      type   = dvo.resource_record_type
      record = dvo.resource_record_value
    }
  }
  zone_id         = data.aws_route53_zone.this.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 60
  allow_overwrite = true
}

resource "aws_acm_certificate_validation" "app" {
  certificate_arn         = aws_acm_certificate.app.arn
  validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}

8. The load balancer, target group, listeners and alias (alb.tf). The centrepiece — everything the concept sections built, wired together:

# alb.tf
resource "aws_lb" "web" {
  name               = "${var.prefix}-alb"
  load_balancer_type = "application"
  internal           = false
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.public[*].id
  drop_invalid_header_fields = true
}

resource "aws_lb_target_group" "web" {
  name        = "${var.prefix}-tg"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = aws_vpc.this.id
  target_type = "instance"
  deregistration_delay = 30

  health_check {
    path                = "/"
    port                = "traffic-port"
    matcher             = "200"
    interval            = 15
    timeout             = 5
    healthy_threshold   = 3
    unhealthy_threshold = 3
  }
}

resource "aws_lb_target_group_attachment" "web" {
  count            = var.instance_count
  target_group_arn = aws_lb_target_group.web.arn
  target_id        = aws_instance.web[count.index].id
  port             = 80
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web.arn
  port              = 80
  protocol          = "HTTP"
  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.web.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.app.certificate_arn
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.web.arn
  }
}

# Alias the app hostname straight at the ALB (no IP to hard-code).
resource "aws_route53_record" "app" {
  zone_id = data.aws_route53_zone.this.zone_id
  name    = var.app_fqdn
  type    = "A"
  alias {
    name                   = aws_lb.web.dns_name
    zone_id                = aws_lb.web.zone_id
    evaluate_target_health = true
  }
}

9. Outputs (outputs.tf). Emit the ALB DNS name, the app URL and the target-group ARN so we can curl and check health:

# outputs.tf
output "alb_dns_name"     { value = aws_lb.web.dns_name }
output "app_url"          { value = "https://${var.app_fqdn}/" }
output "target_group_arn" { value = aws_lb_target_group.web.arn }

10. Init. Downloads the provider and wires the backend:

terraform init
# Initializing the backend...
# Initializing provider plugins...
# - Installing hashicorp/aws v5.6x ...
# Terraform has been successfully initialized!

11. Plan. Pass the two required variables and read the summary line — it must create the whole graph and change nothing unexpected:

export TF_VAR_hosted_zone_name="kloudvin.dev."
export TF_VAR_app_fqdn="app.kloudvin.dev"
terraform plan
# ...
# Plan: 19 to add, 0 to change, 0 to destroy.
# Changes to Outputs:
#   + alb_dns_name = (known after apply)
#   + app_url      = "https://app.kloudvin.dev/"

12. Apply. ⚠️ Billing starts here. The slow steps are the ACM DNS validation (~2–4 min for the CNAME to propagate and ACM to issue) and the instances booting Apache:

terraform apply -auto-approve
# aws_acm_certificate.app: Creation complete after 3s
# aws_route53_record.cert_validation["app.kloudvin.dev"]: Creation complete after 32s
# aws_acm_certificate_validation.app: Still creating... [2m0s elapsed]
# aws_acm_certificate_validation.app: Creation complete after 2m41s
# aws_lb.web: Creation complete after 2m55s
# aws_lb_listener.https: Creation complete after 1s
# Apply complete! Resources: 19 added, 0 changed, 0 destroyed.
# Outputs:
# alb_dns_name = "kv-web-alb-123456789.ap-south-1.elb.amazonaws.com"
# app_url      = "https://app.kloudvin.dev/"

13. Verify — prove the redirect, the TLS, and load balancing. First confirm the HTTP:80 listener redirects; then curl the HTTPS URL a few times and watch the hostname change as the ALB spreads requests across both instances; then read target health from the CLI:

ALB=$(terraform output -raw alb_dns_name)

# a) HTTP is redirected, not served:
curl -sI http://$ALB/ | head -n2
# HTTP/1.1 301 Moved Permanently
# Location: https://kv-web-alb-...elb.amazonaws.com:443/

# b) HTTPS serves, and load-balances (give targets ~60s to pass health first):
curl -s https://app.kloudvin.dev/
# Hello from ip-10-30-0-57.ap-south-1.compute.internal — behind the ALB
curl -s https://app.kloudvin.dev/
# Hello from ip-10-30-1-91.ap-south-1.compute.internal — behind the ALB   # LB!

# c) Both targets healthy from the ALB's own point of view:
aws elbv2 describe-target-health \
  --target-group-arn $(terraform output -raw target_group_arn) \
  --query 'TargetHealthDescriptions[].TargetHealth.State' --output text
# healthy  healthy

That healthy healthy from describe-target-health is the single most useful signal on this whole stack: it is the ALB telling you the health check is passing. If instead you see unhealthy and a 503 in the browser, jump to troubleshooting — the TargetHealth.Reason field names the exact cause.

The verification checklist:

Step Command Expect
ALB DNS resolved terraform output -raw alb_dns_name kv-web-alb-….elb.amazonaws.com
HTTP redirects curl -sI http://$ALB/ 301 + Location: https://…
HTTPS serves curl -s https://app.<domain>/ The Apache “Hello from…” page
Load balancing works repeat the curl Hostname alternates between the two IPs
Targets healthy aws elbv2 describe-target-health … healthy for each target
Cert valid & trusted curl -sv https://app.<domain>/ 2>&1 | grep subject CN=app.<domain> (no -k needed)

14. Destroy. ⚠️ Do this — the ALB bills by the hour.

terraform destroy -auto-approve
# aws_lb.web: Destruction complete after 1m3s
# Destroy complete! Resources: 19 destroyed.

Confirm the ALB is gone (aws elbv2 describe-load-balancers should not list it). The ACM certificate is free and DNS-validated, so it deletes cleanly; the Route 53 validation and alias records are removed with it because Terraform owns them.

Variables, outputs & making it reusable

The demo hard-codes one target group, one health check and one pair of listeners. Real ALBs front several services with several path rules, and copy-pasting blocks is how they rot. Two Terraform patterns turn this into a reusable module: for_each over a map of services to generate a target group + listener rule per service, and — when you’d rather stand on the shoulders of others — the community ALB module.

A for_each-driven services map keeps every new backend to a few lines of data, not a copy-pasted block:

variable "services" {
  type = map(object({
    path_pattern = string
    priority     = number
    health_path  = string
  }))
  default = {
    api = { path_pattern = "/api/*", priority = 100, health_path = "/api/health" }
    web = { path_pattern = "/*",     priority = 200, health_path = "/" }
  }
}

resource "aws_lb_target_group" "svc" {
  for_each = var.services
  name     = "${var.prefix}-${each.key}"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.this.id
  health_check {
    path    = each.value.health_path
    matcher = "200"
  }
}

resource "aws_lb_listener_rule" "svc" {
  for_each     = var.services
  listener_arn = aws_lb_listener.https.arn
  priority     = each.value.priority
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.svc[each.key].arn
  }
  condition {
    path_pattern { values = [each.value.path_pattern] }
  }
}

When you’d rather not own the plumbing, the registry module terraform-aws-modules/alb/aws wraps the load balancer, target groups, listeners and rules behind a tidy input shape — production-tested, and the fastest way to a correct ALB:

Approach You maintain Reach for it when
Raw aws_lb* resources Everything (max control) You need an unusual shape, or you’re learning
for_each over a services map One local module Many similar services, one team’s conventions
terraform-aws-modules/alb/aws Just the inputs Standard ALB, want it correct fast

Whichever you choose, expose the ALB’s dns_name, zone_id and arn, and each target group’s arn, as outputs — downstream stacks (Route 53 aliases, WAF associations, ASG registrations) consume exactly those.

Common mistakes and troubleshooting

The ALB’s signature error is 503 Service Unavailable, and it is worth stating the core truth once: a 503 from an ALB almost never means the load balancer is broken — it means the target group it selected has no healthy members. The ALB is reporting a failure it observed downstream. So every 503 investigation starts at the same place — aws elbv2 describe-target-health — and fans out from there. This is the symptom → cause → fix table to keep open during an incident:

Symptom Likely cause Fix
503, targets unhealthy Health-check path returns non-2xx, or wrong port Point health_check.path at a real 200 URL; port = "traffic-port"; fix matcher
503, target group empty Nothing attached (no attachment / ASG target_group_arns unset) Add aws_lb_target_group_attachment or set the ASG’s target_group_arns
Targets unhealthy, health check times out Web SG doesn’t allow the ALB SG on the traffic port Add ingress on the web SG security_groups = [alb_sg] for the port
502 Bad Gateway Target closed the connection / bad response / wrong protocol_version Check the app is up on the port; for gRPC set protocol_version = "GRPC"
504 Gateway Timeout Target slower than the ALB idle_timeout Raise idle_timeout; fix backend latency
Listener create fails: certificate not found Referenced the raw cert, or cert not validated Reference aws_acm_certificate_validation.<x>.certificate_arn; check DNS records
apply fails: at least two subnets in two AZs ALB given one subnet / one AZ Provide ≥2 subnets in ≥2 distinct AZs
HTTPS works but app.<domain> won’t resolve Missing/incorrect Route 53 alias record Alias A record → aws_lb.dns_name + zone_id, evaluate_target_health
Two listener rules — apply error Duplicate priority on one listener Give every aws_lb_listener_rule a unique priority
Deploys drop requests deregistration_delay too high, or too low for long connections Tune draining: ~30s for web, higher for long-lived
NLB: long connections reset ~6 min 350s TCP idle timeout TCP keepalives < 350s on client and target
NLB: uneven load / same-AZ only Cross-zone LB off (NLB default) enable_cross_zone_load_balancing = true

Because 503 is the workhorse failure, here is the decision matrix that maps what describe-target-health shows you to the specific misconfiguration — walk it top to bottom:

Target health says Meaning Where the bug is
healthy but browser still 503/5xx App flaked after the check, or a rule sends to an empty TG App stability, the listener rule’s target group
unhealthy, Reason Target.ResponseCodeMismatch Check reached the app, got the wrong status matcher vs what the app returns; the health path
unhealthy, Reason Target.Timeout Check can’t reach the target Web SG doesn’t allow the ALB SG; app down on the port
unhealthy, Reason Target.FailedHealthChecks Connection refused / reset App not listening on the port; wrong port
unused / no targets Target group has no members No attachment; ASG target_group_arns unset
draining Target is deregistering Normal during a deploy; waits deregistration_delay

Beyond 503, the gnarliest real-world traps:

The security-group two-step. The most common “all targets unhealthy” cause isn’t the health path — it’s that the web instances’ security group doesn’t allow the ALB’s security group on the traffic port. Reference the ALB SG by ID in the web SG’s ingress (as the demo does), never a CIDR; then the health check can actually reach the target. This is the single highest-yield thing to check first.

The unvalidated-certificate race. Wire the HTTPS listener’s certificate_arn to the aws_acm_certificate_validation resource, not the raw aws_acm_certificate. The validation resource only completes once ACM sees the DNS CNAMEs and issues the cert, so referencing it makes Terraform order things correctly and wait. Reference the raw cert and the listener can be created against a cert that’s still PENDING_VALIDATION, which fails or serves a bad handshake. Also: ACM DNS validation needs the CNAMEs actually in the zoneallow_overwrite = true on the validation record avoids collisions on re-apply.

The two-AZ rule. An ALB must span at least two subnets in two different Availability Zones, and for an internet-facing ALB those must be public subnets (a route to an IGW). Give it one subnet, or two subnets in the same AZ, and the apply fails outright; give it private subnets and it provisions but is unreachable. Model exactly two public subnets in two AZs, as the demo does.

Health-check economics. A health check that’s too aggressive (short interval, path that hits the database) can hammer a struggling backend into the ground, and one that’s too lax (long interval, high unhealthy_threshold) leaves a dead instance in rotation for minutes. Point the check at a cheap, dependency-free /healthz that returns 200 without touching a database, and keep interval at 15–30s with a 2–3 threshold.

Auth and permissions. The provider itself needs rights: your principal must be able to create load balancers, target groups, EC2 instances, security groups, ACM certs and Route 53 records. A missing route53:ChangeResourceRecordSets on the hosted zone, or elasticloadbalancing:*, is the usual first wall — and it shows up as an AccessDenied on that specific resource, not on the ALB.

Cost, cleanup & production notes

The economics are dominated by the ALB’s continuous billing. An ALB bills a fixed hourly charge plus Load Balancer Capacity Units (LCUs) — a blend of new connections, active connections, processed bytes and rule evaluations — so even an idle ALB runs the clock. The NLB is priced similarly (NLCUs) but is often cheaper for pure L4 throughput. Indicative Mumbai / ap-south-1, on-demand, July 2026:

Resource Rough monthly if left up Notes
Application Load Balancer ~₹1,400 + LCU (~$16+) Hourly + capacity units
Network Load Balancer ~₹1,400 + NLCU (~$16+) Similar; cheaper per-GB at L4
t3.micro EC2 ~₹1,500 (~$18) The target group members
ACM public certificate Free You pay only for what it fronts
Route 53 hosted zone ~₹42 (~$0.50) + per-query Zone you already own
This demo, one week ~₹800 (~$10) Which is why you destroy it

Cleanup is terraform destroy — and it’s cleaner than most stacks because ACM and Route 53 records are all Terraform-owned and free. The one gotcha: if you enabled enable_deletion_protection = true, destroy will refuse until you flip it back to false and re-apply. Always confirm with aws elbv2 describe-load-balancers no longer listing the ALB.

Production hardening, the five that matter:

  1. Remote, locked state. The backend "s3" block (S3 + DynamoDB lock, or S3-native use_lockfile) shown in versions.tf is non-negotiable for a team — local state on a load-balancing tier is how two engineers clobber each other’s ALB.
  2. Least privilege and deletion protection. Set enable_deletion_protection = true on prod load balancers so no one (and no stray destroy) removes the front door. Scope the Terraform principal to exactly the ELB, EC2, ACM and Route 53 actions it needs.
  3. HTTPS-only, modern TLS, and a WAF. Redirect all HTTP to HTTPS (as the demo does), pin a modern ssl_policy (TLS 1.2+/1.3), and attach an aws_wafv2_web_acl_association with managed OWASP + rate-limit rules on any public ALB.
  4. Access logs and tags. Turn on access_logs to S3 for post-hoc 5xx forensics, and rely on default_tags so every resource carries project/owner/env for cost attribution and cleanup.
  5. Watch drift. Someone will “quickly” add a listener rule or bump a health path in the console. Run terraform plan on a schedule (or a drift-detection pipeline) so config that drifts from code is caught, not discovered during an incident.

Going deeper

The hands-on build gets you a correct, single-service ALB. Production adds a handful of knobs the demo left at their defaults — how traffic is actually distributed, what the box really is behind that DNS name, and how Terraform’s own lifecycle bites when you change or import one. This is the advanced layer, built on facets the core lesson only pointed at.

How targets are actually chosen: routing algorithm and slow start

By default an ALB spreads requests round-robin across healthy targets — simple, even, and completely oblivious to how busy each target already is. That is fine until one target is slow (a cold JVM, a long GC pause, a noisy neighbour): round-robin keeps feeding it at the same rate and tail latency spikes. The fix is a target-group attribute, surfaced in Terraform as load_balancing_algorithm_type:

resource "aws_lb_target_group" "web" {
  # ...name, port, protocol, vpc_id, health_check as before...
  load_balancing_algorithm_type     = "least_outstanding_requests"  # not round_robin
  load_balancing_anomaly_mitigation = "off"                          # only valid with weighted_random
  slow_start                        = 30    # ramp new targets over 30s (0 = disabled)
}

least_outstanding_requests (LOR) sends each new request to the target with the fewest in-flight requests, so it naturally routes around a slow instance instead of piling onto it. weighted_random distributes randomly by target weight and is the only algorithm that supports anomaly mitigation — set load_balancing_anomaly_mitigation = "on" and the ALB temporarily sheds traffic from a target whose error rate spikes above its peers. And slow_start gives a freshly-registered target a ramp window (30–900 s) during which it receives a linearly increasing share of traffic — vital for JIT-compiled runtimes or cold caches that would fall over if handed a full share the instant they pass their first health check.

load_balancing_algorithm_type Picks the target by Reach for it when
round_robin (default) Even rotation, ignores load Uniform, fast, stateless targets
least_outstanding_requests Fewest in-flight requests Uneven request cost / variable latency
weighted_random Random by weight; supports anomaly mitigation You want automatic shedding of a flaky target

Two caveats: slow_start is ignored while a target is the group’s only member (there is nothing to ramp against), and it is incompatible with least_outstanding_requests — you pick a warm-up ramp or in-flight-aware routing, not both.

What an ALB actually is behind the DNS name

The aws_lb resource returns a dns_name, never an IP, and that is not a convenience — it is the architecture. An ALB is not one box; it is a fleet of nodes, one per enabled Availability Zone, each with its own elastic network interface (ENI) and a private IP drawn from that AZ’s subnet. That is why you hand it subnets and why it demands at least two. AWS publishes an A record per node behind the single DNS name and rotates them, so a client resolving the name is already being load-balanced across nodes before any node ever selects a target. The fleet scales itself by adding nodes as traffic climbs — but a sudden 10× spike (a launch, a flash sale) can outrun that scaling, which is why AWS offers pre-warming for known events. You choose the address family with ip_address_type:

ip_address_type The ALB serves Use for
ipv4 (default) IPv4 only Standard workloads
dualstack IPv4 + IPv6 Clients on either stack
dualstack-without-public-ipv4 IPv6 public, IPv4 private only IPv6-first, dodging the IPv4 address charge

Two operational consequences fall straight out of the fleet model. First, never hard-code an ALB’s IP — it changes as nodes come and go; always alias the Route 53 record at the DNS name, as the demo does. Second, those per-AZ ENIs consume real IPs in your subnets, so a cramped /28 public subnet can genuinely run out of addresses while the ALB tries to scale — size load-balancer subnets with headroom.

The target-group name trap: replacement and create_before_destroy

Here is the Terraform-specific gotcha that catches every team eventually. The name on an aws_lb or aws_lb_target_group is immutable — change it and Terraform must replace the resource. But AWS will not let two target groups (or two load balancers) hold the same name at the same instant, so the default destroy-then-create order deadlocks: Terraform cannot create the new one while the old one still owns the name. The same collision fires on any change that forces replacement — switching target_type, changing the group’s port or protocol. The fix is a two-part idiom: a generated name via name_prefix (max 6 characters) plus create_before_destroy:

resource "aws_lb_target_group" "web" {
  name_prefix = "kvweb"          # <= 6 chars; AWS appends a unique suffix
  port        = 80
  protocol    = "HTTP"
  vpc_id      = aws_vpc.this.id

  lifecycle {
    create_before_destroy = true  # new TG (unique name) up before the old is torn down
  }
}

Now a replacement stands up the new target group under a different generated name first, re-points the listener at it, and only then destroys the old one — no name clash, no window where the listener has nowhere to forward. This is the single most common reason a terraform apply fails halfway on this resource family and leaves a half-migrated listener behind.

Importing and refactoring a live load-balancing tier

You will often inherit an ALB somebody built by hand in the console. Bring it under Terraform with import blocks (Terraform 1.5+) — one per resource, because a load-balancing tier is a graph, not a single object, and there is no “import the whole ALB” shortcut:

import {
  to = aws_lb.web
  id = "arn:aws:elasticloadbalancing:ap-south-1:ACCOUNT_ID:loadbalancer/app/kv-web-alb/abc123"
}
import {
  to = aws_lb_target_group.web
  id = "arn:aws:elasticloadbalancing:ap-south-1:ACCOUNT_ID:targetgroup/kv-web-tg/def456"
}
import {
  to = aws_lb_listener.https
  id = "arn:aws:elasticloadbalancing:ap-south-1:ACCOUNT_ID:listener/app/kv-web-alb/abc123/ghi789"
}

Run terraform plan -generate-config-out=alb.tf to scaffold the HCL, reconcile it against reality, then apply. The load balancer, target group, listener and each listener rule all import by their ARN; the one resource you cannot import is aws_lb_target_group_attachment — it is a synthetic join, not a real AWS object — so re-declare it and let Terraform register the target afresh. When you later refactor — say, moving two static attachments created with count to a for_each keyed by instance ID — use a moved block so Terraform re-keys state instead of destroying and recreating live registrations:

moved {
  from = aws_lb_target_group_attachment.web[0]
  to   = aws_lb_target_group_attachment.web["i-0abc123"]
}

mTLS, desync mitigation and X-Forwarded-For

Three security knobs the demo left at their defaults. Mutual TLS (mTLS) makes the ALB require a client certificate — the pattern for B2B APIs and service-to-service edges. You create an aws_lb_trust_store (a CA bundle staged in S3) and reference it from the HTTPS listener:

resource "aws_lb_trust_store" "clients" {
  name                             = "kv-client-ca"
  ca_certificates_bundle_s3_bucket = aws_s3_bucket.ca.bucket
  ca_certificates_bundle_s3_key    = "client-ca-bundle.pem"
}

# ...on the HTTPS aws_lb_listener, add:
  mutual_authentication {
    mode            = "verify"          # off | verify | passthrough
    trust_store_arn = aws_lb_trust_store.clients.arn
  }

verify rejects any client whose cert is not signed by your trust store; passthrough forwards the presented cert to the target to validate itself. Independently, desync_mitigation_mode on the aws_lb (monitor / defensive / strictest, default defensive) governs how strictly the ALB rejects ambiguous HTTP that could enable request-smuggling — raise it to strictest on security-sensitive fronts. And because an ALB terminates the client connection, the target sees the ALB’s IP unless it reads X-Forwarded-For; the xff_header_processing_mode argument (append / preserve / remove) and enable_xff_client_port control exactly what that header carries, which matters the moment a downstream WAF or rate-limiter keys on client IP.

NLB security groups and the client-IP hairpin trap

Two NLB subtleties that quietly cost afternoons. First, NLBs now support security groups (since 2023) — but only if you attach one at creation time. You cannot add an SG to an NLB that was created without one, so always give a new NLB a security_groups argument even if the rules start permissive; retrofitting means rebuilding the NLB. On a PrivateLink-fronted NLB, enforce_security_group_inbound_rules_on_private_link_traffic (on / off) decides whether those SG rules also screen endpoint traffic. Second, preserve_client_ip carries a hairpinning trap: when it is on and a target reaches the NLB and gets routed back to itself, the source and destination IP become identical and the flow breaks on a loopback NAT collision. It also means the target’s security group must now allow the real client CIDRs, not the NLB’s — flip it on expecting “the target simply sees the client” and you can lock yourself out of your own service. Leave preserve_client_ip off for intra-VPC NLBs whose targets might call back through them, and reserve it for the cases that genuinely need the original client address at L4.

Cheat-sheet

The dense reference for this tier — resources, the arguments you reach for most, and the verification commands:

Resource Purpose Must-set arguments
aws_lb The load balancer load_balancer_type, subnets (≥2 AZ), security_groups (ALB)
aws_lb_target_group Health-gated target pool port, protocol, vpc_id, target_type, health_check
aws_lb_listener Port/proto entry point load_balancer_arn, port, protocol, default_action
aws_lb_listener_rule Path/host routing listener_arn, priority, action, condition
aws_lb_target_group_attachment Attach one target target_group_arn, target_id, port
aws_lb_listener_certificate Extra SNI certs listener_arn, certificate_arn
aws_acm_certificate (+ _validation) Listener TLS cert domain_name, validation_method = "DNS"
aws_wafv2_web_acl_association Attach WAF (ALB only) resource_arn, web_acl_arn
Choose ALB NLB
load_balancer_type application network
Layer / routing L7 · host/path/header L4 · port/protocol
Static IP No (DNS name) Yes (subnet_mapping + EIP)
WAF Yes No
Cross-zone Always on (free) Opt-in (enable_cross_zone_load_balancing)
Client IP X-Forwarded-For Preserved natively
Verify with Command
Target health aws elbv2 describe-target-health --target-group-arn <arn>
List load balancers aws elbv2 describe-load-balancers --query 'LoadBalancers[].DNSName'
List listeners aws elbv2 describe-listeners --load-balancer-arn <arn>
Curl redirect curl -sI http://$(terraform output -raw alb_dns_name)/
Curl the app curl -s https://app.<domain>/

Interview and exam questions

1. When would you choose an NLB over an ALB? For raw TCP/UDP traffic, when you need static/Elastic IPs, native client-IP preservation, TLS pass-through to the target, PrivateLink (which requires an NLB), or the absolute lowest latency at the highest throughput. The ALB is for HTTP/S that needs URL/host routing, redirects, or a WAF.

2. An ALB returns 503 for every request. Walk me through diagnosis. Run aws elbv2 describe-target-health. unhealthy → the check reaches the app but gets a bad answer (wrong path/port/matcher, or the app returns non-2xx), or the web SG doesn’t allow the ALB SG on the port. Empty/unused → nothing is attached (no attachment, or the ASG’s target_group_arns isn’t set). healthy but still 503 → a rule is forwarding to a different, empty target group, or the app flaked after the check. Fix at the layer the health state points to; never just recreate the ALB.

3. Why reference aws_acm_certificate_validation.certificate_arn on the HTTPS listener instead of aws_acm_certificate.arn? Because the validation resource only completes once ACM has seen the DNS CNAMEs and issued the certificate. Referencing it makes Terraform’s dependency graph wait for a fully-issued cert before creating the listener, avoiding a “certificate not found / not validated” race. The raw cert can still be PENDING_VALIDATION.

4. How do you make an ALB serve HTTPS only, redirecting HTTP? Two listeners: an HTTP:80 listener whose default_action is type = "redirect" (protocol HTTPS, port 443, HTTP_301), and an HTTPS:443 listener with an ACM certificate_arn, an ssl_policy, and a forward default action. Never forward :80 straight to the target group — that serves cleartext.

5. What’s the difference between target_type = "instance" and "ip"? instance attaches EC2 instance IDs and the ALB routes to the instance’s primary private IP — the classic EC2/ASG case. ip attaches raw IP addresses (VPC, peered VPC, or on-prem over Direct Connect/VPN) — required for awsvpc-mode containers, cross-VPC targets, and hybrid backends. You can’t switch a target group between the two; it forces replacement.

6. How does an Auto Scaling Group get its instances into a target group? You set target_group_arns on the ASG (or use aws_autoscaling_attachment for an existing one). The ASG then registers every instance it launches and deregisters them on scale-in — you never call aws_lb_target_group_attachment for autoscaled instances. Set the ASG’s health_check_type = "ELB" so a target that fails the ALB check is terminated and replaced, closing the self-healing loop.

7. What does deregistration_delay control, and how should you tune it? It’s connection draining — how long the ALB keeps sending in-flight requests to a target you’ve started removing before it’s fully deregistered. Default 300s. Drop it to ~30–60s for stateless web apps so deploys are quick; raise it for long-lived connections you don’t want to cut mid-flight.

8. Why is cross-zone load balancing a non-issue on an ALB but a real decision on an NLB? An ALB always has cross-zone on and free. An NLB has it off by default and billed when on — so without it, an NLB only sends a client to targets in the same AZ the client landed in, which can look like uneven load. Turn it on for even distribution at the cost of inter-AZ data transfer.

9. Can you attach an AWS WAF to an NLB? Why or why not? No. WAF (WAFv2) inspects L7 (HTTP), and an NLB operates at L4 and never sees the HTTP request. WAF associates only with ALB, API Gateway, CloudFront and AppSync. If you must front with an NLB for L4 reasons, terminate TLS and put the WAF on an upstream ALB or CloudFront.

10. (Terraform Associate 003) The target group attachment references aws_instance.web[count.index].id and the listener references aws_lb.web.arn. What guarantees creation order? Terraform’s implicit dependency graph: because each resource references another’s attributes, Terraform orders instance → target group → attachment, and load balancer → listener, automatically. No depends_on is needed for those edges — it’s only for hidden dependencies with no attribute reference.

11. (Terraform Associate 003) You change only the health-check path from / to /healthz. What does terraform plan show, and will it replace the target group? An in-place update (~) to the health_check block, not a replacement — the health check is a mutable property. Plan shows 0 to add, 1 to change, 0 to destroy. (Changing an immutable attribute like target_type or name would force replacement.)

12. Why pin aws with ~> 5.60 and use a remote backend for this stack? A floating provider version means a later apply can change resource behaviour between two green runs; ~> pins the major so upgrades are deliberate. A remote, locked backend (S3 + DynamoDB, or S3-native locking) prevents two engineers from corrupting the state of a shared load-balancing tier.

Practice challenges

Work these against the hands-on project. They escalate from a one-line change to a full refactor. Try each before opening the solution.

1. Give the health check a real endpoint (beginner). The demo probes /. Point it at a dedicated /healthz with matcher = "200-299", then run terraform plan and read the summary line — is this a replacement or an in-place update?

<details> <summary>Solution</summary>

  health_check {
    path                = "/healthz"
    port                = "traffic-port"
    matcher             = "200-299"
    interval            = 15
    timeout             = 5
    healthy_threshold   = 3
    unhealthy_threshold = 3
  }

terraform plan shows Plan: 0 to add, 1 to change, 0 to destroy — a ~ in-place update.

Why: the health_check block is a mutable property of the target group, so changing it never replaces the group (unlike name, port or target_type, which force replacement). </details>

2. Answer unmatched requests at the edge (beginner). Make the HTTPS listener return a clean 404 for anything that doesn’t match a rule, instead of falling through to the app.

<details> <summary>Solution</summary>

Change the HTTPS listener’s default_action from forward to fixed-response, and move the forward into a rule that matches your real hosts/paths:

  default_action {
    type = "fixed-response"
    fixed_response {
      content_type = "text/plain"
      message_body = "Not found"
      status_code  = "404"
    }
  }

Why: a fixed-response default action answers at the ALB with no target group involved — the correct way to serve a canned 404/maintenance page without wasting a backend round-trip. </details>

3. Split by path (intermediate). Route /api/* to a second api target group while /* keeps hitting the web target group.

<details> <summary>Solution</summary>

Add the api target group and a listener rule with a unique, lower priority; the listener’s default action stays as the catch-all:

resource "aws_lb_target_group" "api" {
  name        = "${var.prefix}-api-tg"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = aws_vpc.this.id
  target_type = "instance"
  health_check {
    path    = "/api/health"
    matcher = "200"
  }
}

resource "aws_lb_listener_rule" "api" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 100                       # lower = evaluated first
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.api.arn
  }
  condition {
    path_pattern { values = ["/api/*"] }
  }
}

Why: listener rules are evaluated by ascending priority and the first match wins; the default_action catches everything no rule claims, so /api/* peels off to the API pool and /* falls through to web. </details>

4. Route by load, and warm new targets (intermediate). Switch the web target group from round-robin to least-outstanding-requests and give freshly-launched instances a 30-second ramp.

<details> <summary>Solution</summary>

resource "aws_lb_target_group" "web" {
  # ...name, port, protocol, vpc_id, health_check...
  load_balancing_algorithm_type = "least_outstanding_requests"
  slow_start                    = 30
}

Then confirm the plan is an in-place update — both are mutable attributes.

Why: LOR sends each request to the least-busy target so a slow instance stops attracting its full share, and slow_start ramps a new target’s traffic linearly over 30 s so a cold process isn’t hit with a full load the instant it goes healthy. </details>

5. Make target-group replacement zero-downtime (advanced). You need to change the target group’s port (an immutable field that forces replacement) on a live ALB without a name-clash failure or a gap where the listener has no group.

<details> <summary>Solution</summary>

Replace name with name_prefix and add a create_before_destroy lifecycle so the new group is built (under a fresh generated name) before the old one is removed:

resource "aws_lb_target_group" "web" {
  name_prefix = "kvweb"           # <= 6 chars
  port        = 8080              # the change that forces replacement
  protocol    = "HTTP"
  vpc_id      = aws_vpc.this.id
  lifecycle {
    create_before_destroy = true
  }
}

Why: AWS forbids two target groups sharing a name simultaneously, so a plain replacement deadlocks; name_prefix guarantees the replacement gets a different name and create_before_destroy orders create-then-swap-then-destroy, keeping the listener always pointed at a live group. </details>

6. Swap static attachments for a self-registering fleet (advanced). Replace the two aws_lb_target_group_attachment resources with an Auto Scaling Group that registers its own instances into the target group and lets the ALB’s health check drive replacement.

<details> <summary>Solution</summary>

Delete both aws_lb_target_group_attachment blocks and the static aws_instance pair, add a launch template, and attach the group to the target group via target_group_arns:

resource "aws_autoscaling_group" "web" {
  name                = "${var.prefix}-asg"
  min_size            = 2
  max_size            = 4
  desired_capacity    = 2
  vpc_zone_identifier = aws_subnet.public[*].id

  target_group_arns   = [aws_lb_target_group.web.arn]  # self-registers
  health_check_type   = "ELB"                           # honour the TG health check
  health_check_grace_period = 90

  launch_template {
    id      = aws_launch_template.web.id
    version = "$Latest"
  }
}

Why: the ASG registers every instance it launches into the target group and deregisters on scale-in, so you never call aws_lb_target_group_attachment for autoscaled fleets; health_check_type = "ELB" means an instance that fails the ALB check is terminated and replaced, closing the self-healing loop. </details>

Common beginner mistakes

These are conceptual traps — the mental models that send beginners down the wrong path — as opposed to the symptom-driven fixes in the troubleshooting table above.

“An ALB and an NLB are interchangeable — I’ll just pick one.” The single most expensive mistake in this lesson. They work at different layers and do different jobs: reaching for an ALB to front a raw Postgres or Redis pool fails outright (it only speaks the HTTP family), and reaching for an NLB when you need URL routing, HTTP→HTTPS redirects or a WAF leaves you unable to do the job at all. Right model: choose the layer first — do you need to read the HTTP request? — then the service.

“The load balancer holds the targets.” It doesn’t. The target group holds the targets and owns the health check; the load balancer only references target groups through its listeners. That decoupling is the whole point — it’s why an Auto Scaling Group registers into a target group, not into the ALB, and why you can move a target group between listeners. Right model: ALB/NLB → listener → (rule) → target group → targets, wired by ARN.

“Point the health check at / — it’s fine.” The app root often touches a database, renders a heavy page, or 302-redirects to a login — any of which the check reads as unhealthy, and now every target drops out and you get a 503 wall. Right model: probe a cheap, dependency-free /healthz that returns a plain 200 without touching a backing service, and make matcher match what it actually returns.

“Just forward port 80 straight to the target group.” That serves your app in cleartext and quietly trains users to trust http://. Right model: the HTTP:80 listener’s only job is a redirect to HTTPS (301); real traffic is only ever served by the HTTPS:443 listener that terminates TLS from ACM.

“Reference the ACM certificate directly on the listener.” Point the listener’s certificate_arn at aws_acm_certificate.app.arn and Terraform can create the listener while the cert is still PENDING_VALIDATION, which fails or serves a broken handshake. Right model: reference aws_acm_certificate_validation.app.certificate_arn so the dependency graph waits for a fully-issued certificate before the listener is built.

“Turn on stickiness — it makes things more reliable.” Stickiness pins a client to one target, which defeats even load distribution and turns one busy client into one hot instance; it also means a target dying takes its pinned sessions with it. Right model: prefer stateless apps and no stickiness; enable it only when session state genuinely lives on the target and can’t be externalised.

“One subnet is enough, and private subnets are fine for a public ALB.” An ALB legally requires two subnets in two AZs — one subnet fails the apply — and an internet-facing ALB needs public subnets (a route to an IGW); give it private ones and it provisions but is unreachable. Right model: exactly two public subnets in two AZs for a public ALB, as the demo builds.

“A 503 means the ALB is broken, so I’ll recreate it.” It almost never means that. A 503 is the ALB reporting that the target group it selected has no healthy members — a downstream failure it observed, not its own. Right model: every 503 starts at aws elbv2 describe-target-health; unhealthy is a health-path/security-group problem, empty is an attachment problem, healthy-but-503 is a rule or app problem. You never fix a 503 by rebuilding the load balancer.

Glossary

Elastic Load Balancing (ELB): AWS’s umbrella service for the four load-balancer generations — Classic, Application, Network and Gateway — that share the elbv2 API (except Classic).

Layer 4 (L4): the transport layer — TCP and UDP. An L4 device routes by IP address and port only, never reading the payload. The NLB is L4.

Layer 7 (L7): the application layer — HTTP/HTTPS. An L7 device reads the request line, headers and URL, so it can route by path or host, terminate TLS, and run a WAF. The ALB is L7.

Application Load Balancer (ALB): an L7 reverse proxy (aws_lb with load_balancer_type = "application"). Terminates TLS, routes by URL/host/header, redirects, and can carry a WAF.

Network Load Balancer (NLB): an L4 forwarder (aws_lb with load_balancer_type = "network"). Static IPs, native client-IP preservation, raw TCP/UDP, PrivateLink, lowest latency.

Classic Load Balancer (CLB): the legacy first generation (aws_elb) — an all-in-one box with no separate target groups. Import it only to migrate off it.

Gateway Load Balancer (GWLB): an L3 balancer (aws_lb with load_balancer_type = "gateway") that transparently steers all IP traffic through third-party appliances via GENEVE on port 6081.

Target group: the pool of backends a load balancer forwards to, plus the health check that decides which members are eligible. Decoupled from the LB and referenced by listeners.

Target type: what a target group holds — instance (EC2 IDs), ip (raw addresses, containers, on-prem), lambda (a function), or alb (an ALB fronted by an NLB). Immutable once set.

Listener: a port + protocol on the load balancer bound to a default action. A production ALB has two — an HTTP:80 redirect and an HTTPS:443 forward.

Default action: what a listener does when no rule matches — forward, redirect, fixed-response, or an authenticate-* login step.

Listener rule: a conditional override on a listener (aws_lb_listener_rule) that matches on path/host/header/method/query/source-IP and forwards elsewhere. Evaluated by ascending priority; first match wins.

Health check: the periodic probe (HTTP/HTTPS/TCP) that decides whether a target receives traffic. A target failing unhealthy_threshold consecutive checks is pulled from rotation; one passing healthy_threshold is returned.

Matcher: the HTTP status code(s) a health check counts as healthy (e.g. 200, 200-299, 200,302). A mismatch here is a classic silent 503 cause.

traffic-port: the health-check port value meaning “probe the same port the target serves on” — the safe default.

Deregistration delay (connection draining): how long the LB keeps sending in-flight requests to a target being removed before it’s fully deregistered. Default 300 s; drop to ~30 s for stateless web apps.

Stickiness (session affinity): pinning a client to one target — lb_cookie (ALB-generated), app_cookie (your cookie), or source_ip (NLB). Defeats even distribution; use only for genuine session state.

Cross-zone load balancing: whether a load-balancer node in one AZ may send to targets in another. Always on and free on an ALB; off by default and billed when enabled on an NLB.

TLS termination: decrypting HTTPS at the load balancer so it can read (and route/firewall) the request. The ALB always terminates; an NLB TLS listener terminates while a TCP listener passes TLS straight through.

ssl_policy: the predefined TLS-version-and-cipher floor for an HTTPS/TLS listener (e.g. ELBSecurityPolicy-TLS13-1-2-2021-06 for TLS 1.2 + 1.3).

SNI (Server Name Indication): the TLS extension that lets one HTTPS listener serve many hostnames, each with its own certificate attached via aws_lb_listener_certificate.

ACM (AWS Certificate Manager): the service that issues and auto-renews the public TLS certificate the HTTPS listener uses; DNS-validated through Route 53 in this lesson.

mTLS (mutual TLS): the ALB requiring a client certificate, validated against an aws_lb_trust_store, via the listener’s mutual_authentication block.

Load balancer capacity unit (LCU/NLCU): the metered billing unit — a blend of new connections, active connections, processed bytes and rule evaluations — charged on top of the fixed hourly rate.

X-Forwarded-For (XFF): the header an ALB adds so the target can see the original client IP, since the ALB terminates the connection and would otherwise appear as the source.

preserve_client_ip: the NLB behaviour that shows the target the real client IP at L4 with no header; default on for instance/ip TCP targets, and the source of the hairpin trap.

Access logs: per-request log lines an ALB writes to S3 for post-hoc 5xx forensics and security auditing, enabled via the access_logs block.

Auto Scaling Group registration: attaching an ASG to a target group via target_group_arns so instances self-register on launch and deregister on scale-in — the production alternative to static attachments.

Key takeaways

TerraformawsALBNLBELBTarget GroupsListenersHealth ChecksACMRoute53WAFaws_lbIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments