Command Palette

Search for a command to run...

Hectal

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.

40 min ~$0.025/hour for the ALB (+ tiny LCU charges) 6 steps 2 break-it drills

By the end of this mission

  • Create an ALB, target group, and listener and connect them
  • Make whole features optional with count and null defaults
  • Use a dynamic block 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.

Listeners with and without a domaindiagram
Rendering diagram…

Part 2

Your project after this mission · 3 files change

shoplite/
  • 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. 1

    Load balancer and target group

    The ALB spans both public subnets. drop_invalid_header_fields is a free hardening setting. The target group uses name_prefix (at most 6 characters) with create_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 = 30 lets 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. 2

    Optional domain variables

    Both default to null. If you own a domain with a Route 53 hosted zone (for example example.com), set hosted_zone_name = "example.com" and domain_name = "api.dev.example.com" in terraform.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. 3

    The HTTP listener: forward or redirect

    One listener, two behaviours. target_group_arn becomes null in redirect mode, and the redirect block 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. 4

    HTTPS: certificate, DNS validation, listener, record

    All count-gated. ACM gives validation records per domain; the for_each map 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_validation waits 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-06 allows 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. 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. 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 apply
    curl -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-... uses target_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.

terminal
$ terraform apply
── what you'll see ──
# aws_lb.main will be updated in-place
~ resource "aws_lb" "main" {
~ subnets = [
- "subnet-0e1f2a3b4c5d6e7f8",
# (1 unchanged element hidden)
]
}
...
╷
│ Error: modifying ELBv2 Load Balancer (arn:...:loadbalancer/app/shoplite-dev-alb/5f1c...)
│ subnets: operation error Elastic Load Balancing v2: SetSubnets, https
│ response error StatusCode: 400, api error ValidationError: At least two
│ subnets in two different Availability Zones must be specified
╵

Break #2

Use a longer name_prefix

Change the target group's name_prefix to "shoplite-api-" and run terraform validate.

terminal
$ terraform validate
── what you'll see ──
╷
│ Error: "name_prefix" cannot be longer than 6 characters
│
│ with aws_lb_target_group.api,
│ on alb.tf line 10, in resource "aws_lb_target_group" "api":
│ 10: name_prefix = "shoplite-api-"
╵

Part 5

Interview questions from this mission

01

How do you make an entire feature, like HTTPS with a custom domain, optional in a Terraform configuration?

02

Walk through how Terraform validates an ACM certificate with DNS.

03

Why does an ECS Fargate service need target_type = "ip" on its target group?

0/4 · 0%