Terraform in Practice #10 Domain, HTTPS, and Completion: Route 53, ACM Validation, CloudFront and provider alias

6 min read

The last piece. Right now the service is reachable only over HTTP, at the ALB’s long auto-generated address. In this part we connect a domain, issue a certificate to turn on HTTPS, and put a CDN in front of the static assets, arriving at the finished picture we drew in #1. From a Terraform point of view there are two new things to learn: the idiom that expresses ACM’s DNS validation as resources, and the provider alias that lets one codebase manage resources in a different region. We assume the domain already has a hosted zone in Route 53 (for buying a domain and the concept of zones, see Domains and DNS basics).

The hosted zone comes in as a data source #

The domain itself is not something this project created, so following the principle from Basics #4, we only read it, without taking management ownership.

dns.tf
# dns.tf (new file)
data "aws_route53_zone" "main" {
  name = "example.com"
}

The ACM certificate and the DNS validation idiom #

An ACM certificate is not issued just by requesting it — it goes through domain ownership validation. The process that in the console is a “create records” button becomes, in Terraform, a three-resource idiom: the request, the validation records, and the wait for validation.

dns.tf
resource "aws_acm_certificate" "alb" {
  domain_name       = "app.example.com"
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "alb_cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.alb.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.main.zone_id
  name    = each.value.name
  type    = each.value.type
  ttl     = 60
  records = [each.value.record]
}

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

The for_each in the middle resource is the for expression from Basics #4 in its most common real-world shape: it turns the list of validation records the certificate demands into a map and creates the records. The final aws_acm_certificate_validation does not create a real resource — it is a waiting device that blocks until validation completes, so any resource that references it is created only after the certificate has been issued. It looks odd the first time, but it is the standard idiom you will meet everywhere ACM is managed as code.

The HTTPS listener and the redirect #

We attach the issued certificate to the ALB. As foreshadowed in #4, the port 80 listener turns into a redirect.

lb.tf
# lb.tf, modified
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = aws_acm_certificate_validation.alb.certificate_arn

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

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

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

The 443 listener taking its certificate ARN from aws_acm_certificate_validation is the finishing touch of the idiom above. Do not forget to add a 443 inbound rule to the ALB security group either. Now the last link on the way to https://app.example.com: the alias record.

dns.tf
resource "aws_route53_record" "app" {
  zone_id = data.aws_route53_zone.main.zone_id
  name    = "app.example.com"
  type    = "A"

  alias {
    name                   = aws_lb.main.dns_name
    zone_id                = aws_lb.main.zone_id
    evaluate_target_health = true
  }
}

provider alias: CloudFront’s us-east-1 requirement #

When we get to the static assets, we meet a famous real-world trap. A certificate attached to CloudFront must live in ACM in us-east-1, regardless of your region. Our provider is ap-northeast-2 — so what do we do? The answer is the provider alias: a feature that registers the same provider one more time with a different configuration.

providers.tf
# added to providers.tf
provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"

  default_tags {
    tags = {
      Project   = "myapp"
      Env       = var.env
      ManagedBy = "terraform"
    }
  }
}
cdn.tf
resource "aws_acm_certificate" "cdn" {
  provider = aws.us_east_1

  domain_name       = "static.example.com"
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

Setting provider = aws.us_east_1 on a resource creates just that resource in Virginia. The validation records and the waiting device attach exactly as in the earlier idiom (the only thing to watch is that the records themselves use the default provider, while the certificate and the wait use the alias). The CloudFront distribution reads the locked-down assets bucket from #5 through OAC (Origin Access Control). The distribution resource has too many arguments to print in full, but the skeleton is simple: the origin is the S3 bucket, the certificate is the cdn certificate we just made, and the bucket policy allows GetObject only when it comes from this distribution. The bucket never becomes public; the only thing facing the world is the CDN.

Completion, and a look back #

With the alias record for static.example.com in place, the diagram from #1 has fully become code. Looking back over the ten parts in terms of decisions rather than resources: we set up tags and the backend on day one (#1), designed security around network tiers (#2 and #3), switched tiers while taking traffic (#4 and #8), put guardrails on the data (#5), removed secrets from the record (#6), narrowed permissions down to references (#7), and refactored the structure without recreation to clone the environment (#9). These decisions are not Terraform syntax — they are infrastructure operations judgment, and Terraform was the tool that turned that judgment into code with review and history.

Once you are done with the hands-on work, clean up. A single destroy takes care of dev, and with it any worry about the bill. One question remains: what does it take for a team to operate this code together, with no human hands running apply?

Recap #

What we covered in this post.

  • An existing hosted zone is read as a data source. ACM issuance is three resources: the request, the validation records (the for_each idiom), and the validation wait
  • The HTTPS listener takes its certificate ARN from the validation-wait resource, so it is created only after issuance completes, and the port 80 listener becomes a 301 redirect
  • A CloudFront certificate must be in us-east-1, and a provider alias lets one codebase manage two regions
  • CloudFront reads the locked-down bucket through OAC. The bucket never becomes public
  • The output of ten parts is not a list of resources but operational decisions preserved in code. Cleanup is a single destroy

Next up is Terraform Operations. Across seven parts, we will cover the team workflow where plan is reviewed in PRs and CI runs apply, then drift handling, and policy and cost guardrails.

X