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.
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 |
2×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 resource — aws_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 zone — allow_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 |
2× 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:
- Remote, locked state. The
backend "s3"block (S3 + DynamoDB lock, or S3-nativeuse_lockfile) shown inversions.tfis non-negotiable for a team — local state on a load-balancing tier is how two engineers clobber each other’s ALB. - Least privilege and deletion protection. Set
enable_deletion_protection = trueon prod load balancers so no one (and no straydestroy) removes the front door. Scope the Terraform principal to exactly the ELB, EC2, ACM and Route 53 actions it needs. - 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 anaws_wafv2_web_acl_associationwith managed OWASP + rate-limit rules on any public ALB. - Access logs and tags. Turn on
access_logsto S3 for post-hoc 5xx forensics, and rely ondefault_tagsso every resource carriesproject/owner/envfor cost attribution and cleanup. - Watch drift. Someone will “quickly” add a listener rule or bump a health path in the console. Run
terraform planon 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
- Layer first, service second. The ALB (L7) is a reverse proxy that reads URLs, terminates TLS, redirects and carries a WAF; the NLB (L4) forwards raw TCP/UDP with static IPs, client-IP preservation and the lowest latency. Both are the same
aws_lb, switched byload_balancer_type. The Classic ELB (aws_elb) is legacy — migrate off it. - A working ALB is four resources.
aws_lb+aws_lb_target_group+aws_lb_listener(usually two — an HTTP redirect and an HTTPS forward) + something that attaches targets (aws_lb_target_group_attachment, or an ASG’starget_group_arns). Listener rules add path/host routing on top. - The health check is the point. A load balancer exists to route around dead targets; a check with the wrong path, port, matcher — or a target SG that blocks the ALB SG — silently marks every target unhealthy and yields a 503. Point it at a cheap
/healthzthat returns 200. - 503 = no healthy targets — not the load balancer. Start every 503 at
describe-target-health.unhealthyis a health-path/SG problem, empty is an attachment problem,healthy-but-503 is a rule or an app problem. Never just recreate the ALB. - TLS from ACM, validated via Route 53, wired to the validation resource. Request a DNS-validated ACM cert, write the CNAMEs into the zone, and point the HTTPS listener at
aws_acm_certificate_validation.<x>.certificate_arnso Terraform waits for a fully-issued cert. Redirect HTTP→HTTPS with aredirectdefault action; pin a modernssl_policy. - Two public subnets, two AZs, and the SG two-step. An internet-facing ALB requires ≥2 public subnets in ≥2 AZs; the targets’ SG must allow the ALB’s SG (by ID) on the traffic port. These two are the most common first-build failures.
- Attach the group, not the instances, in production. Let an Auto Scaling Group register into the target group via
target_group_arnswithhealth_check_type = "ELB", so membership is dynamic and unhealthy instances are replaced. - Build it, verify it, destroy it. An ALB bills by the hour plus LCUs even idle —
terraform destroyis part of the exercise, not an afterthought.