Mission 3.2 · Stage 3 — Compute
An Application Load Balancer (with Optional HTTPS)
Goal: A public ALB across both public subnets with a target group health-checking /healthz, plus an optional HTTPS listener, ACM certificate, and DNS record that switch on when you provide a domain.
By the end of this mission
- Create an ALB, target group, and listener and connect them
- Make whole features optional with
countandnulldefaults - Use a
dynamicblock to include a nested block only when needed - Validate ACM certificates through Route 53 with
for_each
Part 1
Understand it first
Load balancer, listener, target group
The ALB is the entry point with a DNS name. A LISTENER watches a port and protocol (HTTP:80, HTTPS:443) and decides what to do with requests: forward, redirect, or return a fixed response. A TARGET GROUP is the set of backends, which here will be ECS tasks by IP, plus a health check. Terraform creates all three and links them by ARN. It's the same structure as the AWS course, Topic 2.4, now in code.
Fargate tasks have their own network interfaces (awsvpc mode), so the target group must use target_type = "ip", not instance. That detail causes a very common error, which you'll trigger in Mission 3.3.
Optional features with null
Not everyone following this course owns a domain, but real services need HTTPS. The solution is a variable that defaults to null: when domain_name is null, count is 0 for every HTTPS resource and HTTP simply forwards to the app. When it's set, the certificate, validation records, HTTPS listener, and DNS record are created, and HTTP switches to redirecting to HTTPS.
Setting an argument to null means 'as if I never wrote this argument', which is how one listener block can have target_group_arn in forward mode and none in redirect mode.
Dynamic blocks
Arguments can be set to null, but nested BLOCKS (like redirect { ... }) can't; they're either written or not. A dynamic "redirect" block generates zero or more redirect blocks from a collection: for_each = local.https_enabled ? [1] : [] means one block or none. Use dynamic blocks sparingly, since they make code harder to read, but for optional nested config they're the right tool.
Part 2
Your project after this mission · 3 files change
- infra/
- alb.tfnew
- backend.tf
- ecr.tf
- locals.tf
- logs.tf
- network.tf
- outputs.tfmodified
- probe.tf
- providers.tf
- refactors.tf
- security.tf
- storage.tf
- terraform.tfvars
- variables.tfmodified
- versions.tf
Part 3
Build it, step by step
- 1
Load balancer and target group
The ALB spans both public subnets.
drop_invalid_header_fieldsis a free hardening setting. The target group usesname_prefix(at most 6 characters) withcreate_before_destroy, because target groups can't be replaced while a listener points at them. The health check marks a task healthy after 2 passing checks 15 seconds apart.deregistration_delay = 30lets in-flight requests finish during deploys without waiting the default 5 minutes.infra/alb.tfwhole filehcl resource "aws_lb" "main" { name = "${local.name_prefix}-alb" load_balancer_type = "application" security_groups = [aws_security_group.alb.id] subnets = [for s in aws_subnet.public : s.id] drop_invalid_header_fields = true } resource "aws_lb_target_group" "api" { name_prefix = "api-" port = var.app_port protocol = "HTTP" target_type = "ip" vpc_id = aws_vpc.main.id deregistration_delay = 30 health_check { path = "/healthz" matcher = "200" interval = 15 healthy_threshold = 2 unhealthy_threshold = 3 } lifecycle { create_before_destroy = true } } - 2
Optional domain variables
Both default to
null. If you own a domain with a Route 53 hosted zone (for exampleexample.com), sethosted_zone_name = "example.com"anddomain_name = "api.dev.example.com"interraform.tfvars. Otherwise leave them out and everything still works over HTTP.infra/variables.tfadd to filehcl variable "domain_name" { description = "Optional FQDN for the API, e.g. api.dev.example.com. null = HTTP only." type = string default = null } variable "hosted_zone_name" { description = "Route 53 public hosted zone that contains domain_name, e.g. example.com." type = string default = null } - 3
The HTTP listener: forward or redirect
One listener, two behaviours.
target_group_arnbecomesnullin redirect mode, and theredirectblock only exists in redirect mode, thanks to the dynamic block.infra/alb.tfadd to filehcl locals { https_enabled = var.domain_name != null } resource "aws_lb_listener" "http" { load_balancer_arn = aws_lb.main.arn port = 80 protocol = "HTTP" default_action { type = local.https_enabled ? "redirect" : "forward" target_group_arn = local.https_enabled ? null : aws_lb_target_group.api.arn dynamic "redirect" { for_each = local.https_enabled ? [1] : [] content { port = "443" protocol = "HTTPS" status_code = "HTTP_301" } } } } - 4
HTTPS: certificate, DNS validation, listener, record
All
count-gated. ACM gives validation records per domain; thefor_eachmap is keyed by domain name, which is known at plan time, so it avoids the unknown-keys trap from Mission 2.2.aws_acm_certificate_validationwaits until ACM reports the certificate issued, and the HTTPS listener uses ITS ARN, so the listener is only created after validation succeeds.ELBSecurityPolicy-TLS13-1-2-2021-06allows TLS 1.2 and 1.3 only.infra/alb.tfadd to filehcl data "aws_route53_zone" "main" { count = local.https_enabled ? 1 : 0 name = var.hosted_zone_name } resource "aws_acm_certificate" "api" { count = local.https_enabled ? 1 : 0 domain_name = var.domain_name validation_method = "DNS" lifecycle { create_before_destroy = true } } resource "aws_route53_record" "cert_validation" { for_each = local.https_enabled ? { for dvo in aws_acm_certificate.api[0].domain_validation_options : dvo.domain_name => dvo } : {} zone_id = data.aws_route53_zone.main[0].zone_id name = each.value.resource_record_name type = each.value.resource_record_type records = [each.value.resource_record_value] ttl = 60 } resource "aws_acm_certificate_validation" "api" { count = local.https_enabled ? 1 : 0 certificate_arn = aws_acm_certificate.api[0].arn validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn] } resource "aws_lb_listener" "https" { count = local.https_enabled ? 1 : 0 load_balancer_arn = aws_lb.main.arn port = 443 protocol = "HTTPS" ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" certificate_arn = aws_acm_certificate_validation.api[0].certificate_arn default_action { type = "forward" target_group_arn = aws_lb_target_group.api.arn } } resource "aws_route53_record" "api" { count = local.https_enabled ? 1 : 0 zone_id = data.aws_route53_zone.main[0].zone_id name = var.domain_name type = "A" alias { name = aws_lb.main.dns_name zone_id = aws_lb.main.zone_id evaluate_target_health = true } } - 5
Output the URL
The output picks the right URL for either mode.
infra/outputs.tfadd to filehcl output "api_url" { value = local.https_enabled ? "https://${var.domain_name}" : "http://${aws_lb.main.dns_name}" } - 6
Apply and call it — a 503 is the right answer
The ALB takes a couple of minutes to become active. With no registered targets yet, it answers
503 Service Temporarily Unavailable, which proves DNS, the listener, and the security group all work. Tasks arrive in Mission 3.3.terminal$ terraform applycurl -si "$(terraform output -raw api_url)" | head -n 1── expected output ──Plan: 3 to add, 0 to change, 0 to destroy. # (8 if you set a domain)...aws_lb.main: Still creating... [2m0s elapsed]aws_lb.main: Creation complete after 2m32s [id=arn:aws:elasticloadbalancing:...:loadbalancer/app/shoplite-dev-alb/5f1c...]...Apply complete! Resources: 3 added, 0 changed, 0 destroyed.HTTP/1.1 503 Service Temporarily Unavailable
Checkpoint — you should now have
- ✓An internet-facing ALB spans both public subnets and uses the ALB security group.
- ✓Target group
api-...usestarget_type = "ip"and checks/healthz. - ✓
curl $(terraform output -raw api_url)returns 503 (no targets yet). - ✓If you set a domain: the certificate is ISSUED, HTTP redirects to HTTPS, and DNS resolves to the ALB.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Give the ALB only one subnet
Change subnets to [aws_subnet.public[local.azs[0]].id] and apply.
Break #2
Use a longer name_prefix
Change the target group's name_prefix to "shoplite-api-" and run terraform validate.
Part 5
Interview questions from this mission
How do you make an entire feature, like HTTPS with a custom domain, optional in a Terraform configuration?
Walk through how Terraform validates an ACM certificate with DNS.
Why does an ECS Fargate service need target_type = "ip" on its target group?