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 |
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.
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 fourname_serversinto your registrar’s NS records, wait for propagation, then run the fullapply. If you skip this,aws_acm_certificate_validationwill spin until it times out (default 45 min). Setvar.domain_nameto 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
greenrecord aliases a second ALB/target group running the new version, and you shiftweightfrom 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 destroybetween 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
- DNS and TLS are one seam. The value of doing Route 53 + ACM together is the joint: ACM asks for a DNS record to prove ownership, Terraform writes it into the zone with a
for_each, andaws_acm_certificate_validationgates everything downstream until the cert isISSUED. - Alias, not CNAME, at the apex. A CNAME is illegal at the zone apex; an alias A-record points the naked domain at an ALB/CloudFront/S3/API-GW, resolves to real IPs, follows the target, and is free — but only with the target’s
zone_id. - Memorize the
for_eachvalidation pattern.for_eachoverdomain_validation_options(keyed bydomain_name,allow_overwrite = true) →aws_acm_certificate_validation→ wire the validation ARN into the listener. It’s in every real AWS codebase for a reason. - CloudFront certs live in
us-east-1. Always. Use aprovideralias; keep the validation records in the global Route 53 zone. Edge-optimized API Gateway is the same rule (it’s CloudFront underneath). - Routing policies make DNS a traffic tool. Weighted = canary/blue-green; failover = active-passive DR (needs a health check on PRIMARY); latency, geolocation (needs a
*default), and multivalue cover the rest — each is the same resource with a policy block and a uniqueset_identifier. - Delegation and renewal are the quiet traps. A cert stuck in PENDING_VALIDATION is almost always an undelegated zone; a cert that silently expires months later is almost always a deleted validation record. Keep the zone delegated and the records in Terraform.
- Destroy to stop the meter. ACM and the zone are rupees; the ALB is the cost. Use
terraform destroybetween sessions, or front with CloudFront+S3, to keep this an under-₹2,000 exercise.