Route 53 and ACM: Publishing a Custom Domain with DNS Delegation and HTTPS
In the previous chapter, Tasuku was published externally over HTTP for the first time. But as long as it stays on HTTP, anyone can read the contents of the traffic. In this chapter, we delegate a custom domain to Route 53, issue an SSL certificate with ACM, and terminate HTTPS on the ALB's port 443. This is the chapter where we replace the HTTP we shipped with HTTPS.
7.1. Fundamentals of DNS delegation and Route 53 hosted zones
Route 53 is a DNS service that translates domain names into resources such as IP addresses. To manage a domain in Route 53, you first create a hosted zone. A hosted zone is a container that holds the DNS records under that domain.
When you create a hosted zone, Route 53 automatically generates an NS record and an SOA record.1 The NS record indicates which name servers should be queried to resolve that domain's name. If you registered the domain through a registrar, DNS resolvers around the world will only start querying Route 53 once you configure that NS record's content on the registrar's side. This configuration is called DNS delegation. The SOA record holds the zone's administrative information, such as the primary name server and the serial number.
We don't touch the automatically generated NS and SOA records. AWS explicitly advises against adding or deleting these records.2 Technically, if you declare an NS/SOA record with the same content as a separate Terraform resource, it conflicts with the record that already exists. Every resource that has appeared through Chapter 6 was something we built from scratch ourselves. This is the first chapter where we deliberately decide not to touch something AWS created automatically.
A hosted zone is a resource that starts incurring a monthly charge from the moment you create it.3 The ACM certificate itself is free, as long as you combine it with an AWS service that can use certificates in an integrated way, such as ALB.4 We don't write the specific pricing here, since it's subject to revision. If you want to try this yourself, check the current pricing on the Route 53 pricing page.
7.2. Preparing the domain: why tasuku.example can't be used
The domain tasuku.example, which we've used throughout the previous chapters, carries a constraint that none of the other chapters' resource names have. .example is a top-level domain reserved by RFC 2606 for use only in documentation and sample code. No real registry or registrar exists for it, so no one can own this domain. This is the reason Chapter 1 foreshadowed that "a custom domain will be used from Chapter 7 onward, but the domain used in the text can't be registered as-is."5
For this reason, we introduce a variable called var.domain_name starting in this chapter. We don't set a default value for this variable. The code won't work unless the reader explicitly specifies a real domain they own, based on their own judgment. This follows the same idea as var.alert_email in Chapter 2.
The AWS Service Terms prohibit creating a hosted zone for a domain you don't own or have authority over.6 tasuku.example is a fictitious domain reserved by RFC 2606, and no one owns it. The very step of DNS delegation at a registrar can't happen, because the delegation target doesn't actually exist. If you want to actually try the code in this chapter, be sure to specify a real domain you own for var.domain_name.
If you actually run apply, the steps in this chapter don't complete in a single pass. Right after you create the hosted zone, the certificate's DNS validation isn't yet in a state where it can complete.
For the first apply, narrow the scope like terraform apply -target=aws_route53_zone.main and create only the aws_route53_zone. Get the list of name servers with terraform output route53_zone_name_servers, and configure delegation to those name servers in the management console of the registrar where you registered the domain. Wait a while for the delegation to propagate across the entire internet. Once the delegation has taken effect, run the second apply. This time, DNS queries actually reach Route 53, so ACM's DNS validation completes, and the process proceeds all the way to attaching the certificate to the HTTPS listener.
7.3. DNS validation of the certificate with ACM
ACM (AWS Certificate Manager) is a service that manages the issuance and renewal of SSL certificates. Issuing a certificate requires proof that you actually control the domain. There are two methods for this proof, email and DNS validation, and this chapter uses DNS validation. That's because validation completes simply by registering the CNAME record ACM specifies in Route 53, and renewal is automatically re-validated through the same mechanism.
The certificate issued in this chapter covers both the apex domain tasuku.example and the wildcard *.tasuku.example. By including the wildcard, we won't need to reissue an additional certificate when we use api.tasuku.example in Chapter 10.
Both the apex domain and the wildcard domain are validated using the same CNAME record name and record value.7 The domain_validation_options that ACM issues for validation has as many entries as the number of domains the certificate covers, but for the apex and wildcard combination, these two entries point to the identical record. As a result, the Terraform code that creates the validation record ends up trying to create the same record twice. This is expected behavior — as long as you enable allow_overwrite on the record creation, the second creation simply overwrites the first, and no problem occurs.7 If you change the domain configuration the certificate covers in the future, revisit how you manage the records with this convergence behavior in mind.
7.4. The dependency chain from certificate to listener
There are several stages between requesting the issuance of a certificate and actually being able to use it in an HTTPS listener. When you request a certificate, ACM first returns information for a validation CNAME record. Only once you register that record in Route 53 and ACM can confirm the record over DNS does the certificate become issued.
When you express this sequence of steps in Terraform, the certificate ARN that the listener references is taken not from the resource that exists immediately after requesting the certificate, but from the resource that represents completed validation.8 This uses the code's dependency relationships to avoid a situation where a certificate whose validation hasn't finished yet gets attached to the listener.
The dependency from certificate to listener is a one-directional chain — from creating the zone, to registering the validation record, to the certificate's validation completing, to attaching it to the listener — with no back-and-forth relationship along the way. Unlike the "chicken-and-egg" problem covered in Chapter 5, section 5.3, where the backend configuration ends up requiring a bucket that's under that same backend's management, the dependency relationships in this chapter stay within a range that Terraform can resolve the execution order for automatically.
If you actually run apply, the resource that waits for certificate validation while DNS delegation hasn't been completed will keep waiting for 75 minutes by default before timing out.9 The procedure described in the previous section — "create only the zone on the first apply" — is also a practical reason for avoiding this timeout.
7.5. Adding port 443 and redirecting port 80
The security group chain diagram in Chapter 4, section 4.7, showed port 443 as the shape things would take once the series was complete. Here, we make good on that promise. We add an ingress rule for port 443 to the ALB's security group.
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
description = "Port 443 from the internet (HTTPS)"
}
The port 80 listener we built in Chapter 6 was a listener that returned a fixed response. Starting in this chapter, we change what port 80 does. We treat every request received on port 80 as a redirect to port 443, and leave the actual response to the new listener on port 443. There are only two places throughout the series where we deliberately go back and change something we already built: this redirect on port 80, and the switch in Chapter 10 from a fixed response to an actual forward.
The port 443 listener requires specifying an SSL policy in addition to the certificate. An SSL policy is a combination of TLS versions and cipher suites that the ALB supports. The default value when you omit the setting differs depending on whether you create it from the AWS Management Console or through an API such as Terraform.10 The default value through Terraform remains the old policy ELBSecurityPolicy-2016-08, which isn't the policy AWS currently recommends. AWS recommends adopting a newer TLS policy that supports post-quantum cryptography.11 This policy is designed to communicate with clients capable of post-quantum support, clients that support only TLS 1.3, and clients that support only up to TLS 1.2 alike, letting you migrate gradually while maintaining compatibility with existing clients. In this chapter, we explicitly specify this policy.
The SSL policy value specified here is a recommended value managed on the AWS side, and it may be updated at any time. This is a risk on a different axis from this series' policy of "fixing the Terraform and provider versions as of the time of writing, and not updating them until the series is complete." If you try this yourself, check AWS's current recommended policy.
7.6. An alias record for the apex
We configure where the apex domain (tasuku.example itself) points to the ALB using a mechanism called an alias record. In ordinary DNS, you can't place a CNAME record at the apex, the domain's peak.12 An alias record is an extended feature that Route 53 provides, letting you point directly from the apex to another AWS resource such as an ALB. In addition to the target resource's DNS name and hosted zone ID, an alias record requires specifying whether to reflect the target's health check.13 The TTL is fixed at 60 seconds by the alias-specific mechanism, and you can't specify it explicitly.14
resource "aws_route53_record" "apex" {
zone_id = aws_route53_zone.main.zone_id
name = var.domain_name
type = "A"
alias {
name = aws_lb.main.dns_name
zone_id = aws_lb.main.zone_id
evaluate_target_health = true
}
}
We don't provide an AAAA record (an alias for IPv6). ALB only provides an IPv6 alias when the ALB itself is running in dualstack mode.15 The VPC in Chapter 4 hasn't allocated an IPv6 CIDR block, and the ALB in Chapter 6 doesn't specify ip_address_type either, so the prerequisites for dualstack aren't met. IPv6 support isn't included in the scope of this series.
7.7. Terraform implementation
We write everything covered so far into a new file called route53-acm.tf. Following this series' established policy of grouping a chapter's main resources into a single file, we place the SG and listener changes in the existing alb.tf, and put the other new resources in this file.
resource "aws_route53_zone" "main" {
name = var.domain_name
tags = {
Name = "${local.name_prefix}-zone"
}
}
resource "aws_acm_certificate" "main" {
domain_name = var.domain_name
subject_alternative_names = ["*.${var.domain_name}"]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = {
Name = "${local.name_prefix}-cert"
}
}
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.main.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.main.zone_id
name = each.value.name
type = each.value.type
records = [each.value.record]
ttl = 60
}
resource "aws_acm_certificate_validation" "main" {
certificate_arn = aws_acm_certificate.main.arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
We specify lifecycle { create_before_destroy = true } on aws_acm_certificate. This is so that, when a certificate in use by a listener is replaced with a different certificate, the new certificate gets created first and the old one is deleted afterward.16
On the alb.tf side, we rewrite the default_action of the port 80 listener we built in Chapter 6, and newly create the port 443 listener.
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = "80"
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09"
certificate_arn = aws_acm_certificate_validation.main.certificate_arn
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
status_code = "200"
message_body = "Tasuku ALB is up (${var.env})"
}
}
}
The default_action of the port 443 listener also remains the same fixed response as in Chapter 6. Since we don't create the target group until Chapter 10, we can't yet write a forward action with a forwarding destination.
Finally, we add the values that later chapters reference to outputs.tf.
output "route53_zone_id" {
description = "Route 53 hosted zone ID (referenced for future record additions, dig etc. in verification)"
value = aws_route53_zone.main.zone_id
}
output "route53_zone_name_servers" {
description = "List of the hosted zone's name servers (used for NS delegation at the domain registrar in actual operation = domain preparation pass 1)"
value = aws_route53_zone.main.name_servers
}
output "acm_certificate_arn" {
description = "ARN of the ACM certificate (apex + wildcard SAN; the api subdomain in Chapter 10 is also covered by this certificate)"
value = aws_acm_certificate_validation.main.certificate_arn
}
output "alb_listener_https_arn" {
description = "ARN of the port 443 listener (referenced for adding listener rules and switching from fixed response to forward in Chapter 10)"
value = aws_lb_listener.https.arn
}
7.8. Verification
We run terraform fmt -check -recursive -diff and terraform validate to confirm that the syntax and the provider schema are consistent.17 As with previous chapters, verification in this chapter goes only as far as validate; plan and apply aren't included in scope.
This chapter has a deeper verification gap than the previous chapters. VPC, S3, and ALB work correctly if you actually apply them, even with fictional names. The hosted zone creation and ACM DNS validation in this chapter only become meaningful once work outside of Terraform — delegation to a real domain — is complete. validate only confirms that the syntax and schema are consistent; whether DNS delegation actually functions, and whether certificate validation completes, are not included in this series' verification scope.
If you actually apply this with your own real domain, after going through the two-apply procedure described in section 7.2, running curl https://<your-domain>/ returns a 200 status along with the body Tasuku ALB is up (dev). If you access it via http://, you can also confirm that it's forwarded to https:// with a 301 redirect.
Summary
- We don't touch the NS and SOA records that are automatically generated when creating a Route 53 hosted zone
- Since
tasuku.exampleisn't a real domain, we introduce avar.domain_namevariable with no default value starting in this chapter, on the assumption that readers will substitute their own real domain - For an apex + wildcard SAN certificate, the CNAME records for DNS validation converge on identical values, so we absorb this with
allow_overwrite - By taking the ARN from the resource that represents completed certificate validation, we avoid a situation where a certificate whose validation isn't complete gets attached to the listener
- We switched the port 80 listener from a fixed response to a redirect to port 443, and terminated HTTPS at the new port 443 listener
- We configured the A record for the apex as an alias, and omitted the IPv6 AAAA record since the prerequisites for dualstack aren't met
Next steps
- Chapter 8: ECS Part 1: Building a container runtime foundation with an ECR repository and cluster, a task definition, and two kinds of IAM roles
- Chapter 6: ALB: Layer 7 load balancer basics and your first HTTP release
- Series table of contents: The structure of all 12 chapters and how to read through them