Terraform Lesson 51 of 89

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

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.

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.

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