Terraform Lesson 53 of 89

Terraform on AWS: Route 53 DNS (Records, Alias, Failover, Weighted) & ACM SSL Certificates

Two invisible layers sit in front of every real AWS application, and both are exactly where a hurried click in the console turns into an outage or a browser security warning: DNS (the name the world types) and TLS (the certificate that makes the padlock green). On AWS those two are Route 53 and AWS Certificate Manager (ACM), and they are unusually pleasant to run as code — Route 53 is one of the most reliable, feature-dense DNS services anywhere, and ACM issues and auto-renews public certificates for free. This lesson wires both together with Terraform in one working, copy-pasteable demo: a Route 53 hosted zone, an ACM certificate validated the canonical DNS way, an alias A-record at the apex pointing straight at an ALB, and a weighted split for a canary. You will run it end to end — terraform init → plan → apply → dig → verify → destroy.

The reason to teach these two as a unit is that their seam is the whole game. A certificate is useless without a name to put it on; a name is dangerous without a certificate behind it; and the mechanism that proves you own the name — ACM’s DNS validation — is itself a Route 53 record that Terraform has to create for you. Get that seam right and the rest is easy. Get it wrong and you meet the four failures every AWS engineer hits once: a CNAME on the apex that DNS forbids, a certificate stuck in PENDING_VALIDATION forever because its validation record never landed (or the zone was never delegated), an alias with the wrong zone_id that plans clean but resolves nowhere, and a CloudFront distribution that rejects your ACM ARN because the cert wasn’t in us-east-1. This is a Senior-tier, hands-on lesson: it assumes you already know core Terraform — HCL, providers, variables, state, for_each — and that you can authenticate the aws provider. If provider auth and the S3/DynamoDB backend are new to you, the companion Terraform on AWS: getting started, provider authentication & S3/DynamoDB backend covers it in full; this lesson takes that as read and pins hashicorp/aws ~> 5.0 throughout.

What you’ll build

The scenario is the front door for a small public web app. The app runs behind an Application Load Balancer; it must be reachable at the naked apex kv-demo.example and at app.kv-demo.example; every request must be HTTPS with a valid, auto-renewing certificate; and you want the ability to ship a canary — send 10% of traffic to a new version — by editing one number in a file and running apply. In console terms that is a hosted zone, half a dozen record sets, a certificate request, a manual “create record in Route 53” click to validate it, and a listener edit — several of which are easy to get subtly wrong and none of which are diff-able. In Terraform it is one directory you can read, review, plan (so a reviewer sees the canary weight change before it ships), and destroy in a single command.

Concretely, terraform apply will stand up: a public aws_route53_zone; an aws_acm_certificate for kv-demo.example with a *.kv-demo.example wildcard SAN, validation_method = "DNS"; the validation records created by a for_each over domain_validation_options; an aws_acm_certificate_validation that blocks apply until ACM issues; a minimal ALB (security group, target group, and an HTTPS listener that consumes the validated cert ARN); an alias A-record at the apex pointing at the ALB; and a weighted pair on app. that splits 90/10 for a canary. The whole thing costs a few rupees a day if you leave it running (the ALB is the meter) and is fully removed by terraform destroy.

The two services map to Terraform resources like this — keep this table open, it is the spine of the lesson:

AWS service Primary Terraform resource(s) What it models Key companion resource
Route 53 (zone) aws_route53_zone A hosted zone (public or private) + its name servers aws_route53_zone_association (extra VPCs)
Route 53 (records) aws_route53_record Any record set: A/AAAA/CNAME/MX/TXT + alias + routing policy aws_route53_health_check (for failover)
Route 53 (health) aws_route53_health_check An endpoint/latency/calculated health probe referenced by health_check_id
ACM (certificate) aws_acm_certificate A public (or private) X.509 cert with SANs aws_route53_record (the DNS validation CNAMEs)
ACM (validation gate) aws_acm_certificate_validation A blocker that waits until the cert is ISSUED wraps the cert + the validation FQDNs
Wiring aws_lb_listener / viewer_certificate / aws_api_gateway_domain_name Attaches the validated cert to an endpoint the .certificate_arn output of the validation

Why Terraform for this rather than the console, aws CLI, or CloudFormation? Because DNS and certificates are long-lived, security-sensitive, and change-controlled — the exact profile Terraform is built for:

Approach Repeatable Drift-detectable Canary is a… Verdict for DNS + TLS
Console No (manual clicks) No Series of nervous manual edits Fine to learn a record; unsafe as source of truth
aws CLI scripts Partially (imperative) No Bash you hope is idempotent OK for one-off ops, not lifecycle
CloudFormation Yes (declarative) Weak (drift detection is manual/partial) A change set Good on AWS-only shops; clunkier for_each, no cross-cloud
Terraform (aws) Yes Yes (plan = drift) One number in a reviewed PR Best fit: one language for zone+records+cert+listener, reviewable, destroyable

The one honest caveat: DNS delegation and domain registration live partly outside Terraform — the handoff of your domain’s name servers to Route 53 happens at your registrar, and ACM’s DNS validation only completes once that delegation resolves publicly. Half of the traps in this lesson trace back to that boundary. Let’s build the pieces.

Route 53 hosted zones as code

A hosted zone is a container for all the DNS records of one domain (or subdomain). Route 53 has two kinds, and they are genuinely different services that happen to share a resource:

Zone type Resource shape Resolvable from Delegated how Typical use
Public aws_route53_zone (no vpc block) The entire internet NS records at your registrar Your real domain: example.com
Private aws_route53_zone with a vpc { } block Only inside associated VPCs Not delegated — associated Internal names: db.internal, split-horizon DNS

A public zone is a fleet of four authoritative name servers Route 53 assigns you; the world reaches your records only after you copy those four NS values into your domain’s registration (delegation). A private zone answers queries only for resources inside the VPCs you link to it — the backbone of internal service discovery and split-horizon DNS (the same name resolving to a private IP inside the VPC and a public one outside).

Here is a public zone. Note that its useful outputs are zone_id (every record needs it) and name_servers (what you delegate):

resource "aws_route53_zone" "primary" {
  name    = "kv-demo.example"        # the domain this zone is authoritative for
  comment = "Public zone for the demo app"

  # force_destroy = true             # ⚠️ allow destroy even if records exist (see cleanup)
  tags = local.tags
}

output "name_servers" {
  description = "Delegate these four NS values at your registrar."
  value       = aws_route53_zone.primary.name_servers
}

The aws_route53_zone arguments you will actually set:

Argument Purpose Note
name The domain the zone is authoritative for Trailing dot optional; example.com and example.com. are equal
vpc { vpc_id, vpc_region } Presence of the block makes the zone private One or more; add more via aws_route53_zone_association
comment Free-text description Shows in console; handy for ownership
force_destroy Let destroy delete a zone that still holds non-Terraform records Default false → destroy fails if strays exist
delegation_set_id Reuse a fixed set of name servers across zones Lets you pre-delegate NS before the zone exists
tags Cost/ownership tags Zones are cheap but tag anyway

Private hosted zones and VPC association

A private zone needs the vpc block, and the VPC must have enableDnsHostnames and enableDnsSupport turned on or resolution silently fails:

resource "aws_route53_zone" "internal" {
  name = "internal.kv-demo.example"

  vpc {
    vpc_id = aws_vpc.main.id
  }
  tags = local.tags
}

# Associate a SECOND VPC (e.g. a shared-services VPC) with the same private zone
resource "aws_route53_zone_association" "shared" {
  zone_id = aws_route53_zone.internal.zone_id
  vpc_id  = aws_vpc.shared.id
}

Two gotchas that cost real time: (1) you cannot declare all VPCs inline in the vpc block and manage some with aws_route53_zone_association — pick one owner per association or Terraform fights itself on every plan (the standard fix is a lifecycle { ignore_changes = [vpc] } on the zone when you delegate association management to the separate resource); and (2) cross-account association is a two-step handshake — aws_route53_vpc_association_authorization in the zone’s account, then aws_route53_zone_association in the VPC’s account.

Domain registration vs delegation

New engineers conflate three things that are separate. Getting them straight prevents the number-one “why doesn’t my domain resolve” ticket:

Concept Who owns it Terraform’s role The handoff
Domain registration A registrar (Route 53 Domains, GoDaddy, Namecheap…) Mostly none — you register via console/API You pay for and own the name
Hosted zone Route 53 aws_route53_zone creates it Gives you four name_servers
Delegation The registrar, pointing at the zone None (manual/registrar-specific) Put the four NS values on the registered domain

Terraform creates the zone; it cannot register a brand-new domain, and it cannot delegate a domain it doesn’t manage the registration of. If Route 53 is also your registrar, a domain registered there is auto-delegated to a zone you create for it, and the aws_route53domains_registered_domain resource can manage that already-registered domain’s name servers and contacts. Otherwise, delegation is a manual step at whoever holds the registration: copy aws_route53_zone.primary.name_servers into the registrar’s NS fields. Until delegation resolves publicly, nothing in the zone answers — including, crucially, the ACM validation record, which is why a fresh, undelegated zone leaves your certificate stuck in PENDING_VALIDATION.

Records: A/AAAA/CNAME/MX/TXT and the crucial alias

Every record set — no matter the type — is one aws_route53_record. The type is set by the type argument; the value is either a records list (with a ttl) or, for AWS resources, an alias block (no TTL). Start with the plain, TTL-based record types:

Record type What it maps records value shape Typical TTL Notes
A Name → IPv4 ["203.0.113.10"] 60–300 The workhorse; alias A is special (below)
AAAA Name → IPv6 ["2001:db8::1"] 60–300 Same alias option as A
CNAME Name → another name ["target.example.com"] 300 ⚠️ never at the zone apex
MX Mail exchangers ["10 mail1.ex.", "20 mail2.ex."] 3600 preference is part of the string
TXT Arbitrary text (SPF, DKIM, domain verification) ["v=spf1 include:_spf.google.com ~all"] 3600 Quote each string; 255-char chunks
NS Delegate a subdomain to another zone ["ns-1.awsdns...", …] 172800 Delegation, not resolution
SRV Service location (_svc._proto) ["1 10 5060 sip.ex."] 300 priority weight port target
CAA Which CAs may issue certs ["0 issue \"amazon.com\""] 300 Add amazon.com or ACM can’t issue
PTR Reverse DNS (in a reverse zone) ["host.example.com"] 3600 Rare; separate in-addr.arpa zone

Here are the everyday ones as HCL. Note name can be the short label or the FQDN — Route 53 treats www and www.kv-demo.example identically inside the zone:

# CNAME: www → the apex (a name pointing at another name)
resource "aws_route53_record" "www" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "www"                         # → www.kv-demo.example
  type    = "CNAME"
  ttl     = 300
  records = ["kv-demo.example"]
}

# MX: mail routing (preference is inside the string)
resource "aws_route53_record" "mx" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "kv-demo.example"
  type    = "MX"
  ttl     = 3600
  records = ["10 inbound-smtp.kv-demo.example", "20 backup-mx.kv-demo.example"]
}

# TXT: SPF + a domain-verification token
resource "aws_route53_record" "txt" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "kv-demo.example"
  type    = "TXT"
  ttl     = 3600
  records = ["v=spf1 include:_spf.google.com ~all", "google-site-verification=abc123"]
}

# CAA: only let Amazon's CA (ACM) issue — belt-and-braces for TLS
resource "aws_route53_record" "caa" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "kv-demo.example"
  type    = "CAA"
  ttl     = 300
  records = ["0 issue \"amazon.com\""]
}

The aws_route53_record arguments, and the ones people trip on:

Argument Purpose Trap
zone_id Which hosted zone owns this record Public vs private zone — same name, different zone_id
name The record name (short or FQDN) Apex = the bare domain; leave label empty or use the FQDN
type Record type (A, CNAME, …) CNAME at apex is illegal (use alias A)
ttl Seconds resolvers may cache Omit for alias — mutually exclusive
records List of values Omit for alias — mutually exclusive
alias { } Point at an AWS resource No ttl/records; needs the target’s zone_id
set_identifier Unique label per record in a routing policy Required for weighted/failover/latency/geo/multivalue
health_check_id Associate a health check Drives failover and healthy-answer filtering
allow_overwrite Adopt/overwrite an existing record Essential for ACM validation records (apex+wildcard dedup)

Alias records vs CNAME — and why alias wins at the apex

A CNAME says “this name is an alias for that name” — but DNS forbids a CNAME coexisting with the mandatory SOA and NS records that live at every zone apex. So you cannot put kv-demo.example → my-alb.elb.amazonaws.com as a CNAME; the apex must be an A/AAAA. Route 53’s alias record solves this: it’s an A (or AAAA) record that points at an AWS resource by its DNS name + hosted-zone ID, and Route 53 resolves it internally to the target’s current addresses — so it works at the apex, returns real IPs, follows the target automatically, and (unlike a standard query) is free to resolve.

Dimension Alias record (alias { }) CNAME record (type = "CNAME")
Allowed at zone apex Yes (it’s an A/AAAA) No (DNS forbids it)
Points at An AWS resource (ALB, CloudFront, S3, API-GW, another R53 record) Any hostname
Query cost Free (alias to AWS resource) Billed per query
Follows target IP changes Yes, automatically Only the name; target resolves it
Health-aware evaluate_target_health = true No
ttl Managed by the target (omit) You set it
Response type A/AAAA (real addresses) CNAME (a name; extra lookup)

Each alias needs the target’s own hosted-zone ID, which is not your zone’s ID — and using the wrong one is the single most common alias mistake (it plans clean, then resolves to nothing). Reference the target resource’s attribute; never hardcode unless you must:

Alias target alias.name alias.zone_id evaluate_target_health
ALB / NLB aws_lb.x.dns_name aws_lb.x.zone_id true supported
CloudFront aws_cloudfront_distribution.x.domain_name aws_cloudfront_distribution.x.hosted_zone_id (const Z2FDTNDATAQYW2) must be false
S3 website aws_s3_bucket_website_configuration.x.website_endpoint aws_s3_bucket.x.hosted_zone_id (per-region) must be false
API Gateway (regional) aws_api_gateway_domain_name.x.regional_domain_name aws_api_gateway_domain_name.x.regional_zone_id false
API Gateway (edge) ...cloudfront_domain_name ...cloudfront_zone_id false
Another R53 record the target record’s name your own zone_id true supported
# Apex alias → ALB (the pattern the demo uses). No ttl, no records.
resource "aws_route53_record" "apex" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "kv-demo.example"                 # the naked apex
  type    = "A"

  alias {
    name                   = aws_lb.app.dns_name
    zone_id                = aws_lb.app.zone_id      # the ALB's zone_id, NOT yours
    evaluate_target_health = true
  }
}

# Subdomain alias → CloudFront (evaluate_target_health MUST be false here)
resource "aws_route53_record" "cdn" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "cdn"
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.app.domain_name
    zone_id                = aws_cloudfront_distribution.app.hosted_zone_id
    evaluate_target_health = false
  }
}

Routing policies: simple, weighted, failover, latency, geo, multivalue

Plain records answer every query the same way. Routing policies turn Route 53 from a phone book into a traffic-management tool: multiple records share the same name and type but each carries a unique set_identifier and a policy block that tells Route 53 which answer to return for a given query. This is how you do canary deploys, cross-region failover, and latency-based global routing — all in DNS, no extra infrastructure.

Policy Terraform block Route 53 returns Needs health check? Canonical use
Simple (none) The one record No A single, unconditional answer
Weighted weighted_routing_policy { weight } Records in proportion to weight Optional Canary / blue-green, gradual shift
Failover failover_routing_policy { type } PRIMARY if healthy, else SECONDARY Yes (on PRIMARY) Active-passive DR
Latency latency_routing_policy { region } The lowest-latency Region for the client Optional Multi-Region, “nearest healthy”
Geolocation geolocation_routing_policy { continent/country/subdivision } By the client’s geographic location Optional Data residency, localized content
Geoproximity (traffic-flow / CloudFront) By geography + a bias Optional Advanced traffic shaping (Traffic Flow)
Multivalue multivalue_answer_routing_policy = true Up to 8 random healthy records Recommended Cheap client-side “load balancing”

Every non-simple policy shares three rules: (1) all records use the same name + type; (2) each needs a unique set_identifier; (3) attach health_check_id wherever a healthy/unhealthy decision should filter the answer.

Weighted — the canary / blue-green pattern

Two records, same name, split by weight. To ship a canary you nudge the weights (90/10 → 50/50 → 0/100) in a reviewed PR; to roll back, you flip one number:

resource "aws_route53_record" "blue" {
  zone_id        = aws_route53_zone.primary.zone_id
  name           = "app.kv-demo.example"
  type           = "A"
  set_identifier = "blue"
  weighted_routing_policy { weight = 90 }         # 90% of traffic
  alias {
    name                   = aws_lb.blue.dns_name
    zone_id                = aws_lb.blue.zone_id
    evaluate_target_health = true
  }
}

resource "aws_route53_record" "green" {
  zone_id        = aws_route53_zone.primary.zone_id
  name           = "app.kv-demo.example"
  type           = "A"
  set_identifier = "green"
  weighted_routing_policy { weight = 10 }         # 10% — the canary
  alias {
    name                   = aws_lb.green.dns_name
    zone_id                = aws_lb.green.zone_id
    evaluate_target_health = true
  }
}

weight = 0 removes a record from rotation without deleting it (handy to park the old stack). The share each record gets is its weight ÷ the sum of weights, so 90 + 10 = 100 makes the math read as percentages, but any integers work.

Failover — primary/secondary with a health check

Failover only fails over if the PRIMARY has a health check. The health check probes an endpoint; when it flips unhealthy, Route 53 stops handing out the PRIMARY and returns the SECONDARY:

resource "aws_route53_health_check" "primary" {
  fqdn              = "origin.kv-demo.example"
  port              = 443
  type              = "HTTPS"
  resource_path     = "/healthz"
  failure_threshold = 3
  request_interval  = 30            # 30s (or 10s fast)
  tags = merge(local.tags, { Name = "primary-health" })
}

resource "aws_route53_record" "primary" {
  zone_id         = aws_route53_zone.primary.zone_id
  name            = "app.kv-demo.example"
  type            = "A"
  set_identifier  = "primary"
  health_check_id = aws_route53_health_check.primary.id   # ← without this it never fails over
  failover_routing_policy { type = "PRIMARY" }
  alias {
    name                   = aws_lb.primary.dns_name
    zone_id                = aws_lb.primary.zone_id
    evaluate_target_health = true
  }
}

resource "aws_route53_record" "secondary" {
  zone_id        = aws_route53_zone.primary.zone_id
  name           = "app.kv-demo.example"
  type           = "A"
  set_identifier = "secondary"
  failover_routing_policy { type = "SECONDARY" }
  alias {
    name                   = aws_lb.dr.dns_name          # the DR Region's ALB
    zone_id                = aws_lb.dr.zone_id
    evaluate_target_health = true
  }
}

Latency, geolocation and multivalue

Latency routing sends a client to the Region that measures fastest for them; you create one record per Region with latency_routing_policy { region = "..." }. Geolocation routes by where the query comes from and requires a default (country = "*") or clients outside your listed locations get no answer. Multivalue returns up to eight random healthy records — a cheap, health-aware spread when you don’t want a load balancer:

# Latency: nearest healthy Region
resource "aws_route53_record" "lat_mumbai" {
  zone_id        = aws_route53_zone.primary.zone_id
  name           = "app.kv-demo.example"
  type           = "A"
  set_identifier = "ap-south-1"
  latency_routing_policy { region = "ap-south-1" }
  alias {
    name                   = aws_lb.mumbai.dns_name
    zone_id                = aws_lb.mumbai.zone_id
    evaluate_target_health = true
  }
}

# Geolocation: India gets a local answer; everyone else hits the default
resource "aws_route53_record" "geo_in" {
  zone_id        = aws_route53_zone.primary.zone_id
  name           = "app.kv-demo.example"
  type           = "A"
  set_identifier = "india"
  geolocation_routing_policy { country = "IN" }
  ttl            = 60
  records        = ["203.0.113.10"]
}

resource "aws_route53_record" "geo_default" {
  zone_id        = aws_route53_zone.primary.zone_id
  name           = "app.kv-demo.example"
  type           = "A"
  set_identifier = "default"
  geolocation_routing_policy { country = "*" }     # ← the mandatory catch-all
  ttl            = 60
  records        = ["203.0.113.20"]
}

# Multivalue: up to 8 random healthy answers
resource "aws_route53_record" "mv_1" {
  zone_id                          = aws_route53_zone.primary.zone_id
  name                             = "app.kv-demo.example"
  type                             = "A"
  set_identifier                   = "mv-1"
  multivalue_answer_routing_policy = true
  ttl                              = 60
  records                          = ["203.0.113.10"]
  health_check_id                  = aws_route53_health_check.primary.id
}

Health checks in depth

aws_route53_health_check is a small resource with a big blast radius — it’s what makes failover, multivalue, and DNS-level health filtering real. The type decides what it probes:

type Probes Key extra args Use
HTTP / HTTPS An HTTP(S) endpoint returns 2xx/3xx fqdn/ip_address, port, resource_path Web endpoints
HTTP_STR_MATCH / HTTPS_STR_MATCH Body contains a string search_string Deep health (/healthz says “OK”)
TCP A TCP connect succeeds port Non-HTTP services
CLOUDWATCH_METRIC A CloudWatch alarm state cloudwatch_alarm_name, cloudwatch_alarm_region Metric-based (latency, errors)
CALCULATED AND/OR of child checks child_healthchecks, child_health_threshold Composite “is the whole stack up”
RECOVERY_CONTROL An ARC routing control routing_control_arn Application Recovery Controller
Argument Meaning Values
failure_threshold Consecutive fails before “unhealthy” 1–10 (default 3)
request_interval Seconds between checks 30 (standard) or 10 (fast, costs more)
measure_latency Record latency graphs bool, immutable after create
regions Which checker Regions probe ≥3 recommended; omit for global
invert_healthcheck Treat healthy as unhealthy bool
disabled Pause the check bool

ACM: certificates and DNS validation done right

AWS Certificate Manager (ACM) issues, stores, and — the killer feature — auto-renews X.509 certificates for free, as long as they protect AWS resources (ALB, CloudFront, API Gateway, etc.). You never generate a CSR, never handle a private key, never get paged at 2 a.m. because a cert expired. The whole job in Terraform is: request the cert, prove you own the domain, and attach the issued ARN to a listener.

aws_acm_certificate requests the cert. The two decisions that matter are the SANs (extra names on one cert) and the validation method:

Argument Purpose Note
domain_name The primary FQDN on the cert e.g. kv-demo.example
subject_alternative_names Extra names (incl. wildcards) ["*.kv-demo.example"] covers all subdomains
validation_method "DNS" or "EMAIL" Always DNS for automation
key_algorithm RSA_2048 / EC_prime256v1 / EC_secp384r1 Default RSA_2048
certificate_authority_arn Use a private CA (ACM PCA) instead of public Omit for free public certs
lifecycle { create_before_destroy = true } Issue the replacement before dropping the old Recommended — avoids a naked-listener gap
resource "aws_acm_certificate" "app" {
  domain_name               = "kv-demo.example"
  subject_alternative_names = ["*.kv-demo.example"]   # wildcard SAN
  validation_method         = "DNS"
  key_algorithm             = "RSA_2048"

  lifecycle {
    create_before_destroy = true
  }
  tags = local.tags
}

DNS vs EMAIL validation

You prove domain control one of two ways. Only one is automatable, and it’s the one that enables free auto-renewal:

Method How you prove control Automatable Auto-renews Verdict
DNS Publish a CNAME ACM specifies, in your zone Yes (Terraform writes it) Yes (if the CNAME stays) Always use this
EMAIL Click a link mailed to admin@domain etc. No (human clicks) No (re-validate each time) Legacy; avoid

The canonical for_each validation pattern

This is the pattern to memorize — it appears in every serious AWS Terraform codebase. aws_acm_certificate exposes a domain_validation_options set (one entry per name on the cert), each carrying the exact CNAME record ACM wants. You for_each over that set to create the records, then an aws_acm_certificate_validation resource blocks apply until ACM confirms issuance — so anything downstream (the listener) only proceeds once the cert is real:

# 1) Create one validation CNAME per distinct name on the cert.
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
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true                       # ← apex + wildcard share one record; overwrite, don't collide
  zone_id         = aws_route53_zone.primary.zone_id
  name            = each.value.name
  type            = each.value.type            # always CNAME for DNS validation
  records         = [each.value.record]
  ttl             = 60
}

# 2) Block until ACM sees the records and issues the cert.
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]
}

The domain_validation_options attributes you consume:

Attribute What it is Used as
domain_name The name this option validates The for_each map key
resource_record_name The CNAME record name to create aws_route53_record.name
resource_record_type Always CNAME for DNS validation aws_route53_record.type
resource_record_value The CNAME target ACM checks for aws_route53_record.records[0]

Two subtleties that separate seniors from the copy-paste crowd. First, allow_overwrite = true is load-bearing: a cert for kv-demo.example and *.kv-demo.example produces two domain_validation_options entries whose validation record is identical — same name, same value — so the for_each yields two instances that both try to write the same record. Without allow_overwrite, the second errors “record already exists”; with it, they idempotently converge on one record. (Keying the map by dvo.domain_name — not by record name — is the documented HashiCorp pattern precisely so both entries survive into the map.) Second, aws_acm_certificate_validation creates nothing in DNS — it’s a synchronization primitive that polls ACM until ISSUED, giving you a resource whose certificate_arn output means “safe to use.” Always wire listeners to that output, never to aws_acm_certificate.app.arn directly, or Terraform may attach a still-pending cert.

The us-east-1 rule for CloudFront

The trap that catches everyone once: a certificate attached to a CloudFront distribution must live in us-east-1 (N. Virginia) — full stop, regardless of where your origins, buckets, or users are. Regional services (ALB, API Gateway regional, NLB) use a cert in their own Region. You handle both by declaring a provider alias for us-east-1 and creating the CloudFront cert with provider = aws.us_east_1:

Attaching to Cert must be in Provider
CloudFront us-east-1 (always) provider = aws.us_east_1
ALB / NLB The load balancer’s Region default provider
API Gateway (regional) The API’s Region default provider
API Gateway (edge-optimized) us-east-1 (it’s CloudFront under the hood) provider = aws.us_east_1
provider "aws" {
  region = var.region              # e.g. ap-south-1 — for the ALB, zone, etc.
}

provider "aws" {
  alias  = "us_east_1"             # a SECOND aws provider, pinned to N. Virginia
  region = "us-east-1"
}

# CloudFront's cert — note the provider override
resource "aws_acm_certificate" "cdn" {
  provider          = aws.us_east_1
  domain_name       = "cdn.kv-demo.example"
  validation_method = "DNS"
  lifecycle { create_before_destroy = true }
  tags = local.tags
}

resource "aws_acm_certificate_validation" "cdn" {
  provider                = aws.us_east_1
  certificate_arn         = aws_acm_certificate.cdn.arn
  validation_record_fqdns = [for r in aws_route53_record.cdn_cert_validation : r.fqdn]
}

The validation records still live in your (global) Route 53 zone with the default provider — DNS is not regional — but the certificate and its _validation resource use aws.us_east_1. Wildcards work identically: domain_name = "*.kv-demo.example" issues one cert for every first-level subdomain (but not the apex, and not nested a.b.kv-demo.example — add those as explicit SANs). Renewal is automatic: ACM re-issues DNS-validated certs roughly 60 days before expiry with zero downtime, provided the validation CNAMEs remain in the zone — which is the best reason to keep them in Terraform, where they won’t be quietly deleted.

Wiring the validated certificate into a listener

The cert is worthless until something serves it. Attach the validated ARN — aws_acm_certificate_validation.app.certificate_arn, not the raw cert — so the attachment waits for issuance:

# ALB HTTPS listener
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.app.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"   # modern TLS 1.2/1.3 policy
  certificate_arn   = aws_acm_certificate_validation.app.certificate_arn

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

# CloudFront viewer certificate (cert from the us-east-1 provider)
# viewer_certificate {
#   acm_certificate_arn      = aws_acm_certificate_validation.cdn.certificate_arn
#   ssl_support_method       = "sni-only"
#   minimum_protocol_version = "TLSv1.2_2021"
# }

# API Gateway (regional) custom domain
# resource "aws_api_gateway_domain_name" "api" {
#   domain_name              = "api.kv-demo.example"
#   regional_certificate_arn = aws_acm_certificate_validation.api.certificate_arn
#   endpoint_configuration { types = ["REGIONAL"] }
# }
Endpoint Where the cert ARN goes The value to use
ALB / NLB aws_lb_listener.certificate_arn (+ aws_lb_listener_certificate for extra SNI certs) aws_acm_certificate_validation.app.certificate_arn
CloudFront viewer_certificate.acm_certificate_arn the us-east-1 validation output
API Gateway (regional) aws_api_gateway_domain_name.regional_certificate_arn the regional validation output
API Gateway (edge) aws_api_gateway_domain_name.certificate_arn the us-east-1 validation output

The ALB and target-group internals — subnets, security groups, deregistration delay, health checks — are the subject of the dedicated Terraform on AWS: ELB — ALB, NLB & target groups lesson; here we build the smallest real ALB that can hold an HTTPS listener, so the DNS + cert flow is end-to-end.

Hands-on: build it with Terraform

Now the centerpiece — a complete directory you can copy, apply, dig, and destroy. It builds the front door from the diagram: a hosted zone → an ACM cert validated by the for_each pattern → a minimal ALB whose HTTPS listener consumes the validated cert → an apex alias record → a weighted canary pair.

Left-to-right Terraform AWS architecture: a Terraform zone with the CLI and an S3+DynamoDB remote-state backend; a Route 53 zone showing the hosted zone, an apex alias A-record and a health check; an ACM zone with the certificate and the for_each cert_validation records; and a Targets zone with an ALB HTTPS listener and a CloudFront distribution. Numbered badges mark alias-over-CNAME at the apex, the weighted canary, failover with a health check, ACM DNS validation via for_each, the us-east-1 rule for CloudFront, and managed renewal.

Read it left→right: Terraform (state in S3, locked by DynamoDB) owns the Route 53 zone; ACM requests a certificate and Terraform writes its DNS validation records back into that zone with a for_each; once ACM issues, the certificate attaches to the ALB’s HTTPS listener; and an alias A-record at the apex resolves callers to the ALB, with a health check ready to drive failover and a weighted split doing the canary. The six badges are the six things that bite in production — we hit each below.

⚠️ Prerequisite — delegation. ACM DNS validation only completes when the validation CNAME is publicly resolvable, which means the hosted zone must be delegated. Two clean ways: (a) use a domain registered in Route 53 (auto-delegated to a zone you create), or (b) create the zone, run terraform apply -target=aws_route53_zone.primary, copy the four name_servers into your registrar’s NS records, wait for propagation, then run the full apply. If you skip this, aws_acm_certificate_validation will spin until it times out (default 45 min). Set var.domain_name to a domain you actually control.

The directory has five files:

File Contains
versions.tf required_version, required_providers, backend "s3", the two provider "aws" blocks (default + us_east_1)
variables.tf Inputs: region, domain name, tags
main.tf Zone, ACM cert + for_each validation + validation gate, SG + ALB + target group + HTTPS listener, apex alias, weighted canary pair
outputs.tf Name servers, cert ARN/status, ALB DNS name, the app URL
terraform.tfvars Your actual values

versions.tf — providers pinned, S3 remote state (locked by DynamoDB), and the crucial us_east_1 alias:

terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }

  # Remote state on S3, locked by a DynamoDB table
  backend "s3" {
    bucket         = "kv-tfstate-prod-001"
    key            = "route53-acm/terraform.tfstate"
    region         = "ap-south-1"
    dynamodb_table = "kv-tfstate-lock"
    encrypt        = true
  }
}

provider "aws" {
  region = var.region
}

provider "aws" {
  alias  = "us_east_1"          # for any CloudFront/edge cert (not used by the ALB demo, but wired for reuse)
  region = "us-east-1"
}

variables.tf:

variable "region" {
  type    = string
  default = "ap-south-1"       # Mumbai
}

variable "domain_name" {
  type        = string
  description = "A domain whose NS are delegated to this Route 53 zone."
  default     = "kv-demo.example"
}

main.tf — the whole front door:

data "aws_vpc" "default" { default = true }

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

locals {
  tags = {
    project   = "route53-acm-demo"
    managedBy = "terraform"
    env       = "demo"
  }
}

# ---------- Route 53 hosted zone ----------
resource "aws_route53_zone" "primary" {
  name = var.domain_name
  tags = local.tags
}

# ---------- ACM certificate (DNS-validated) ----------
resource "aws_acm_certificate" "app" {
  domain_name               = var.domain_name
  subject_alternative_names = ["*.${var.domain_name}"]
  validation_method         = "DNS"

  lifecycle {
    create_before_destroy = true
  }
  tags = local.tags
}

# The canonical for_each validation-record pattern
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
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  zone_id         = aws_route53_zone.primary.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 60
}

# Block apply until ACM issues
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]
}

# ---------- Minimal ALB to hold the HTTPS listener ----------
resource "aws_security_group" "alb" {
  name_prefix = "demo-alb-"
  vpc_id      = data.aws_vpc.default.id

  ingress {
    description = "HTTPS from anywhere"
    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"]
  }
  tags = local.tags
}

resource "aws_lb" "app" {
  name               = "demo-r53-alb"
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = data.aws_subnets.default.ids
  tags               = local.tags
}

resource "aws_lb_target_group" "app" {
  name     = "demo-r53-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id
  health_check {
    path                = "/healthz"
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
  tags = local.tags
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.app.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.app.certificate_arn  # ← validated ARN

  default_action {
    type = "fixed-response"
    fixed_response {
      content_type = "text/plain"
      message_body = "hello over TLS"
      status_code  = "200"
    }
  }
}

# ---------- Apex alias → ALB ----------
resource "aws_route53_record" "apex" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = var.domain_name          # the naked apex
  type    = "A"
  alias {
    name                   = aws_lb.app.dns_name
    zone_id                = aws_lb.app.zone_id
    evaluate_target_health = true
  }
}

# ---------- Weighted canary on app.<domain> (both point at the one ALB in the demo) ----------
resource "aws_route53_record" "app_blue" {
  zone_id        = aws_route53_zone.primary.zone_id
  name           = "app.${var.domain_name}"
  type           = "A"
  set_identifier = "blue"
  weighted_routing_policy { weight = 90 }
  alias {
    name                   = aws_lb.app.dns_name
    zone_id                = aws_lb.app.zone_id
    evaluate_target_health = true
  }
}

resource "aws_route53_record" "app_green" {
  zone_id        = aws_route53_zone.primary.zone_id
  name           = "app.${var.domain_name}"
  type           = "A"
  set_identifier = "green"
  weighted_routing_policy { weight = 10 }   # the canary; in prod this aliases a SECOND stack
  alias {
    name                   = aws_lb.app.dns_name
    zone_id                = aws_lb.app.zone_id
    evaluate_target_health = true
  }
}

The two weighted records alias the same ALB here so the demo applies with one load balancer; in a real canary the green record aliases a second ALB/target group running the new version, and you shift weight from 10 → 50 → 100 across reviewed PRs. See the weighted section above and the ELB/ALB lesson for the second stack.

outputs.tf:

output "name_servers" {
  description = "Delegate these at your registrar (skip if the domain is Route 53-registered)."
  value       = aws_route53_zone.primary.name_servers
}

output "certificate_arn" {
  value = aws_acm_certificate_validation.app.certificate_arn
}

output "alb_dns_name" {
  value = aws_lb.app.dns_name
}

output "app_url" {
  value = "https://${var.domain_name}"
}

Run it, step by step

Step 1 — terraform init. Downloads the AWS provider and wires the S3 backend:

Initializing the backend...
Initializing provider plugins...
- Installing hashicorp/aws v5.x.x...
Terraform has been successfully initialized!

Step 2 — terraform plan -out tfplan. If your zone is already delegated (Route 53-registered domain), plan the whole thing; otherwise, first do the two-phase delegation from the prerequisite box. Expect a create-only plan:

Plan: 12 to add, 0 to change, 0 to destroy.

The 12: the zone, the ACM cert, two cert_validation records (apex + wildcard — they dedupe to one record via allow_overwrite, but Terraform tracks two instances), the _validation gate, the security group, the ALB, the target group, the HTTPS listener, the apex alias, and the two weighted records.

Step 3 — terraform apply tfplan. ⚠️ This creates real (billed) resources — the ALB is the meter. Watch the ordering: the cert is requested instantly, the validation records write, then Terraform waits on aws_acm_certificate_validation while ACM confirms — typically 30 seconds to a few minutes on a delegated zone. Only then does the HTTPS listener attach the ARN. On success:

Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

Outputs:
alb_dns_name    = "demo-r53-alb-123456789.ap-south-1.elb.amazonaws.com"
app_url         = "https://kv-demo.example"
certificate_arn = "arn:aws:acm:ap-south-1:111122223333:certificate/abcd-…"
name_servers    = tolist(["ns-123.awsdns-45.com", …])

Step 4 — verify with dig and the CLI. Confirm each layer independently — not just “apply succeeded”:

Check Command Expected
Zone name servers dig +short NS kv-demo.example The four awsdns servers
Apex resolves to the ALB dig +short kv-demo.example The ALB’s public IPs (an A answer, not a CNAME)
The validation CNAME exists dig +short CNAME _abc123.kv-demo.example _xyz.acm-validations.aws.
Cert is ISSUED aws acm describe-certificate --certificate-arn <arn> --query 'Certificate.Status' --output text ISSUED
Weighted record set aws route53 list-resource-record-sets --hosted-zone-id <zid> --query "ResourceRecordSets[?Name=='app.kv-demo.example.']" Two sets, blue/green, weights 90/10
HTTPS works, valid cert curl -sI https://kv-demo.example HTTP/2 200, no cert warning
TLS chain from the CLI `openssl s_client -connect kv-demo.example:443 -servername kv-demo.example </dev/null 2>/dev/null openssl x509 -noout -subject -issuer`

If dig +short kv-demo.example returns nothing, the zone isn’t delegated yet (see the prerequisite). If the cert never leaves PENDING_VALIDATION, the validation record didn’t land in a resolvable zone — the same root cause.

Step 5 — terraform destroy. ⚠️ Tears down real resources and stops the ALB meter:

Plan: 0 to add, 0 to change, 12 to destroy.
...
Destroy complete! Resources: 12 destroyed.

The zone destroys cleanly because Terraform created every record in it. If you’d added records by hand (or another tool did), destroy fails until you either remove them or set force_destroy = true on the zone. Deleting the zone does not un-delegate your domain at the registrar — that NS delegation is yours to clean up if you’re done with the name.

Variables, outputs & making it reusable

The demo hardcodes structure but the moving parts are already variables. To make it a reusable module, lift the zone-agnostic pieces (cert + validation + records) into modules/dns-cert/ and drive the records with a for_each over a map, so “one app record” becomes “N records” without new resource blocks:

variable "records" {
  type = map(object({
    name    = string
    type    = string
    ttl     = optional(number, 300)
    records = optional(list(string))
    alias   = optional(object({
      name                   = string
      zone_id                = string
      evaluate_target_health = optional(bool, true)
    }))
  }))
  default = {}
}

resource "aws_route53_record" "this" {
  for_each = var.records
  zone_id  = aws_route53_zone.primary.zone_id
  name     = each.value.name
  type     = each.value.type

  # simple/plain record
  ttl     = each.value.alias == null ? each.value.ttl : null
  records = each.value.alias == null ? each.value.records : null

  # alias record (mutually exclusive with ttl/records)
  dynamic "alias" {
    for_each = each.value.alias == null ? [] : [each.value.alias]
    content {
      name                   = alias.value.name
      zone_id                = alias.value.zone_id
      evaluate_target_health = alias.value.evaluate_target_health
    }
  }
}

A sensible module input surface:

Input Type Why expose it
domain_name string The zone per environment
create_zone bool Create the zone, or look it up with a data source
subject_alternative_names list(string) Extra names/wildcards on the cert
records map(object) Add records without touching the module
region / us_east_1_cert string / bool Regional cert vs a CloudFront (us-east-1) cert
tags map(string) Cost/ownership tags

You don’t have to hand-roll the cert plumbing — the community modules are mature and tested:

Need Registry module Roll-your-own when
ACM cert + DNS validation terraform-aws-modules/acm/aws You want the for_each visible in review, or bespoke SAN logic
Route 53 zones + records terraform-aws-modules/route53/aws You have a strict internal record convention
ALB + listeners + TG terraform-aws-modules/alb/aws You need full control of listener rules/actions

terraform-aws-modules/acm/aws implements exactly the pattern in this lesson (it takes a zone_id and does the for_each + _validation internally) and is the pragmatic default for real projects; keep the records inline when you want the routing-policy logic legible in a PR. For when a local module should graduate to a shared, versioned one, the Terraform on AWS: 3-tier architecture, modules & remote state lesson walks the whole ladder.

Common mistakes and troubleshooting

Every row here is something that has cost a real engineer real time on exactly this stack:

# Symptom Cause Fix
1 Cert sits in PENDING_VALIDATION forever; apply hangs on aws_acm_certificate_validation Validation CNAME isn’t publicly resolvable — usually the zone isn’t delegated Delegate the zone’s name_servers at the registrar (or use a Route 53-registered domain); confirm with dig before re-running
2 apply error: CNAME at apex / “RRSet of type CNAME with DNS name … is not permitted at apex” You used type = "CNAME" for the naked domain Use an alias A-record at the apex; CNAME only on subdomains
3 Record plans fine but resolves to nothing alias.zone_id is your zone’s ID, not the target’s Use aws_lb.x.zone_id / aws_cloudfront_distribution.x.hosted_zone_id, never your aws_route53_zone id
4 Validation records collide: “record set already exists” Apex + wildcard produce the same validation CNAME Add allow_overwrite = true and key the for_each by dvo.domain_name
5 CloudFront rejects the ACM ARN / “cert must be in us-east-1” Cert created in a regional provider Create it with provider = aws.us_east_1; validation records still go in the (global) zone
6 Listener attaches a cert that isn’t ready; brief TLS errors Wired to aws_acm_certificate.arn instead of the validation output Use aws_acm_certificate_validation.app.certificate_arn so it waits for ISSUED
7 Failover doesn’t fail over PRIMARY record has no health_check_id Attach an aws_route53_health_check to the PRIMARY; set evaluate_target_health on aliases
8 Geolocation clients outside your list get no answer Missing the default (country = "*") record Always add a geolocation_routing_policy { country = "*" } catch-all
9 alias block error: “ttl/records conflict” You set ttl/records and an alias They’re mutually exclusive — an alias has no ttl/records
10 terraform destroy on the zone fails: “HostedZone not empty” Records exist that Terraform didn’t create Remove the stray records, or set force_destroy = true on the zone
11 Weighted split ignores your ratio Records share a set_identifier, or one has weight = 0 unintentionally Give each a unique set_identifier; check the weights
12 Cert renews… then breaks months later Someone deleted the validation CNAME Keep validation records in Terraform so they persist; ACM needs them for auto-renewal
13 New cert every apply (perpetual diff) SAN order/case churn, or no create_before_destroy Sort/normalize SANs; add lifecycle { create_before_destroy = true }

Four of these deserve a sentence of context. Row 1 (PENDING_VALIDATION) is the number-one first-run failure and it is almost always delegation: ACM literally queries public DNS for the CNAME it asked for, so a zone whose NS aren’t delegated at the registrar can never satisfy it — Terraform will block on the _validation resource until it times out. Row 3 (alias zone_id) plans clean because Terraform can’t know the target’s zone is wrong until Route 53 tries to resolve it; the discipline is to always reference the target resource’s zone_id attribute. Row 4 (collision) is the apex+wildcard subtlety — the single most common reason a copied validation block fails — and allow_overwrite = true is the one-line fix. Row 12 (renewal) is the quiet killer: everything works for a year, then the cert fails to auto-renew because the validation record was cleaned up; keeping it in Terraform is what prevents it.

Cost, cleanup & production notes

Left running, this demo is cheap, and the certificate is free:

Resource Config Rough monthly cost Notes
ACM certificate Public, DNS-validated ₹0 Free for public certs on AWS resources; auto-renews free
Route 53 hosted zone 1 public zone ~₹42 ($0.50) + query fees $0.50/zone/mo; alias-to-AWS queries are free
Route 53 queries Standard queries ~₹34 ($0.40) per million Alias→AWS resource queries don’t count
Route 53 health check 1 basic (AWS endpoint) ~₹0 (AWS endpoints) / ~₹63 ($0.75, non-AWS) Fast interval / string-match cost more
Application Load Balancer 1 ALB, minimal traffic ~₹1,500–1,900 ($18–22) The dominant cost — LCU + hourly; the meter
Total ~₹1,600–2,000/mo Almost all of it is the ALB

⚠️ The ALB is what spins the meter — ACM, the zone, and the records are rupees. If you’re only learning the DNS + cert flow, you can drop the ALB entirely and point the apex alias at a CloudFront distribution over an S3 bucket (near-zero idle cost), or simply terraform destroy between sessions. Nothing here needs to run overnight.

Cleanup is one command: terraform destroy removes all 12 resources. Two residues to know: the domain delegation at your registrar is not Terraform’s to remove (un-delegate it yourself if you’re done with the name), and if anything added un-managed records to the zone, destroy needs force_destroy = true.

Five production hardening notes for this exact layer:

Hardening What to change Why
Keep validation records forever Never delete cert_validation; manage them in Terraform ACM needs them to auto-renew; deleting = silent expiry later
Modern TLS policy ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" Enforce TLS 1.2/1.3; drop legacy ciphers
Add a CAA record 0 issue "amazon.com" at the apex Only ACM/Amazon can issue certs for your domain
Lock down state S3 backend with encrypt = true, SSE-KMS, bucket policy, DynamoDB lock State holds the zone + ARNs; treat it as production data
Health-checked failover Attach aws_route53_health_check to the PRIMARY; test it DNS failover only works if the health check exists and flips

Two of those tie back to the wider build: the ALB this fronts is provisioned in full — target groups, listener rules, deregistration — in the ELB/ALB lesson, and the S3+DynamoDB backend that protects this state is set up in the getting-started lesson.

Cheat-sheet

Resources and their load-bearing arguments:

Resource Must-set arguments Watch out
aws_route53_zone name (+ vpc {} for private) force_destroy for non-empty zones; delegate NS
aws_route53_record zone_id, name, type, then ttl+records or alias {} ttl/records vs alias are mutually exclusive
aws_route53_record (alias) alias { name, zone_id, evaluate_target_health } Use the target’s zone_id; CF/S3 need evaluate_target_health = false
aws_route53_record (weighted) set_identifier, weighted_routing_policy { weight } Unique set_identifier; weight = 0 parks it
aws_route53_record (failover) set_identifier, failover_routing_policy { type }, health_check_id Health check goes on PRIMARY
aws_route53_health_check type, (fqdn/ip_address), port, resource_path measure_latency is immutable
aws_acm_certificate domain_name, validation_method = "DNS" create_before_destroy; wildcard ≠ apex
aws_acm_certificate_validation certificate_arn, validation_record_fqdns Creates nothing — it’s the wait/gate
aws_lb_listener (HTTPS) protocol = "HTTPS", certificate_arn, ssl_policy Wire the validated ARN

The canonical validation block (memorize this):

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, record = dvo.resource_record_value, type = dvo.resource_record_type } }
  allow_overwrite = true
  zone_id = aws_route53_zone.primary.zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}
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]
}

Command quick-reference:

Task Command
Init / plan / apply terraform init · terraform plan -out tfplan · terraform apply tfplan
Two-phase delegation terraform apply -target=aws_route53_zone.primary → delegate NS → terraform apply
Get name servers terraform output name_servers
Cert status aws acm describe-certificate --certificate-arn <arn> --query 'Certificate.Status' --output text
Resolve apex (expect A, not CNAME) dig +short kv-demo.example
Check the validation CNAME dig +short CNAME _<hash>.kv-demo.example
List a record set aws route53 list-resource-record-sets --hosted-zone-id <zid>
Test HTTPS curl -sI https://kv-demo.example
Inspect the served cert `openssl s_client -connect kv-demo.example:443 -servername kv-demo.example </dev/null 2>/dev/null
Destroy terraform destroy

Interview and exam questions

1. Why can’t you use a CNAME at the zone apex, and what do you use instead? DNS forbids a CNAME coexisting with the mandatory SOA/NS records at the apex. On Route 53 you use an alias A/AAAA record, which points at an AWS resource (ALB, CloudFront, S3, API-GW) by DNS name + hosted-zone ID, resolves to real addresses, works at the apex, follows the target automatically, and is free to query.

2. Walk through the canonical ACM DNS-validation pattern in Terraform. aws_acm_certificate (with validation_method = "DNS") exposes domain_validation_options. You for_each over that set — keyed by dvo.domain_name, with allow_overwrite = true — to create one aws_route53_record (CNAME) per name. Then aws_acm_certificate_validation takes the cert ARN and the list of record FQDNs and blocks apply until ACM issues. Downstream resources wire to that resource’s certificate_arn.

3. Why allow_overwrite = true on the validation records? A cert for example.com and *.example.com produces two domain_validation_options whose validation CNAME is identical. The for_each yields two instances that both write the same record; allow_overwrite makes them idempotently converge instead of erroring “record already exists.”

4. Your certificate is stuck in PENDING_VALIDATION. Diagnose. ACM queries public DNS for the validation CNAME. If the hosted zone isn’t delegated at the registrar (NS records not pointed at Route 53), the record can never be seen. Fix: delegate the zone’s name_servers (or use a Route 53-registered domain), confirm with dig, then re-run. Also check the record landed in the right zone and the account matches.

5. Where must a CloudFront certificate live, and how do you do it in Terraform? In us-east-1, always — regardless of origin/user location. Declare a second provider "aws" with alias = "us_east_1" and create the cert (and its _validation) with provider = aws.us_east_1. Regional services (ALB, regional API-GW) use a cert in their own Region. Validation records still live in the global Route 53 zone.

6. Explain weighted routing for a canary. Two records share the same name+type, each with a unique set_identifier and weighted_routing_policy { weight }. Route 53 returns them in proportion to weight, so 90/10 sends 10% to the canary; you shift to 50/50, then 100/0, in reviewed PRs, and roll back by flipping one number. weight = 0 parks a record without deleting it.

7. Why doesn’t your failover policy fail over? Because the PRIMARY record has no health_check_id. Failover only triggers when Route 53 sees the PRIMARY as unhealthy, which requires an attached aws_route53_health_check. Also set evaluate_target_health on alias targets so target health propagates.

8. Alias vs CNAME — give three concrete differences. (a) Alias works at the apex, CNAME doesn’t; (b) alias to an AWS resource is free to query, CNAME is billed; © alias returns A/AAAA (real IPs) and auto-follows the target, CNAME returns a name requiring another lookup. Alias needs the target’s zone_id; CNAME just takes a hostname string.

9. Why wire the listener to aws_acm_certificate_validation.certificate_arn rather than aws_acm_certificate.arn? The raw certificate resource exists as soon as the request is made — before it’s issued. aws_acm_certificate_validation only completes once ACM reports ISSUED, so its certificate_arn output is a dependency that means “the cert is real.” Wiring to it guarantees the listener attaches a usable cert.

10. How does ACM auto-renewal work, and how do you not break it? For DNS-validated certs, ACM re-issues ~60 days before expiry with zero downtime — provided the validation CNAMEs remain resolvable. Keep those records in Terraform so they’re never garbage-collected. Deleting them is the classic cause of a cert that renewed fine for a year, then silently expired.

11. (Associate-style) You need the same private zone resolvable from two VPCs. How? Put the first VPC in the vpc {} block of aws_route53_zone, and add the second with aws_route53_zone_association (or an aws_route53_vpc_association_authorization + association handshake across accounts). Ensure both VPCs have enableDnsSupport/enableDnsHostnames on, or resolution fails silently.

12. (Associate-style) terraform destroy fails to delete the hosted zone. Why, and two fixes? The zone still contains records Terraform didn’t create (added by hand or another tool). Fix by (a) removing the stray records first, or (b) setting force_destroy = true on the aws_route53_zone so Terraform deletes them for you on destroy.

Key takeaways

TerraformawsRoute 53ACMDNSTLSalias-recordsweighted-routingfailoverhealth-checksCloudFrontremote-stateIaC
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