Hook
HTTPS certificates are the invisible backbone of secure web applications, but provisioning them across production infrastructure often becomes a manual bottleneck. ACM certificates with Terraform: lessons from production teaches us how to automate certificate lifecycle management end-to-end, from DNS validation through renewal, so your team stops worrying about expiration dates and focuses on shipping features.
The pain of manual certificate management
Before we automate, let's acknowledge why certificate management matters. In every production environment we've worked with—whether running e-commerce platforms, SaaS dashboards, or ride-hailing backends—certificates expire unexpectedly, domains get missed, and renewal windows slip past. Manual renewal processes introduce human error, require tribal knowledge, and create operational debt. Infrastructure as Code removes that friction entirely by declaring certificates as managed resources that self-renew automatically.
AWS Certificate Manager (ACM) paired with Terraform gives us exactly this: we define the certificate once in code, Route 53 handles DNS validation automatically, and AWS manages renewal without our intervention. Let's walk through how this works in production scenarios we've encountered.
DNS validation records via Route 53
ACM can validate domain ownership two ways: email validation (manual, slow) or DNS validation (automatic, reliable). We always choose DNS validation in production because it integrates seamlessly with Terraform and Route 53.
When you request an ACM certificate, AWS creates validation records that must exist in your DNS zone. Terraform can create these records automatically, then wait for validation to complete. Here's the pattern:
resource "aws_acm_certificate" "main" {
domain_name = "example.com"
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
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
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = aws_route53_zone.main.zone_id
}
resource "aws_acm_certificate_validation" "main" {
certificate_arn = aws_acm_certificate.main.arn
timeouts {
create = "5m"
}
depends_on = [aws_route53_record.cert_validation]
}
The for_each loop deserves attention: it iterates over domain_validation_options, which gives us the exact DNS records AWS needs. We set allow_overwrite = true because sometimes multiple certificates target the same domain (during rollouts, for instance). The depends_on ensures Route 53 records exist before we try to validate the certificate.
In practice, this takes 30 seconds to a few minutes. We've rarely seen validation take longer than the 5-minute timeout unless Route 53 propagation is delayed across regions—which is why we prefer this over email validation in any automated CI/CD pipeline.
Wildcard certificates for subdomains
One certificate can cover your primary domain and all subdomains using wildcard notation. This is a game-changer when you have dynamic subdomain architectures (staging, preview builds, customer subdomains) because a single wildcard certificate covers them all.
A common production scenario: you run api.example.com, app.example.com, admin.example.com, plus dynamically generated preview environments. Instead of requesting five separate certificates, request one for *.example.com and one for example.com itself:
resource "aws_acm_certificate" "wildcard" {
domain_name = "example.com"
subject_alternative_names = ["*.example.com"]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "wildcard_validation" {
for_each = {
for dvo in aws_acm_certificate.wildcard.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = aws_route53_zone.main.zone_id
}
resource "aws_acm_certificate_validation" "wildcard" {
certificate_arn = aws_acm_certificate.wildcard.arn
depends_on = [aws_route53_record.wildcard_validation]
}
The subject_alternative_names list covers additional domains and patterns. AWS validates all of them via the same DNS records. This approach scales well: add ten subdomains, still one certificate.
A production warning: wildcard certificates don't cover two levels deep. *.example.com covers api.example.com but not v1.api.example.com. If you need that, add an explicit subject_alternative_names entry for *.api.example.com.
us-east-1 for CloudFront
CloudFront is a global service, but its SSL/TLS certificates must live in us-east-1, regardless of where you actually serve traffic. This is a non-obvious constraint that catches teams during their first CloudFront deployment.
If your primary infrastructure lives in eu-west-1 or ap-southeast-1, you still need to request the certificate in us-east-1:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "eu-west-1"
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
resource "aws_acm_certificate" "cloudfront" {
provider = aws.us_east_1
domain_name = "example.com"
subject_alternative_names = ["*.example.com"]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "cloudfront_validation" {
for_each = {
for dvo in aws_acm_certificate.cloudfront.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
zone_id = aws_route53_zone.main.zone_id
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
}
resource "aws_acm_certificate_validation" "cloudfront" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.cloudfront.arn
depends_on = [aws_route53_record.cloudfront_validation]
}
resource "aws_cloudfront_distribution" "main" {
enabled = true
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.cloudfront.arn
ssl_support_method = "sni-only"
minimum_tls_version = "TLSv1.2_2021_06"
}
# ... other configuration
}
Notice the provider aliases. The certificate resource and validation explicitly use aws.us_east_1, while Route 53 records live in your primary region (Route 53 is global, so the region doesn't matter there, but we typically use the primary region for consistency). CloudFront then references the certificate ARN directly. This pattern works across all multi-region deployments.
Wait for validation before use
A subtle but critical point: the certificate ARN exists immediately after you request it, but it's not usable until validation completes. If you attach an unvalidated certificate to a CloudFront distribution or load balancer, the resource will still create (since the ARN exists) but fail to serve traffic.
The aws_acm_certificate_validation resource solves this. It blocks terraform apply until validation succeeds:
resource "aws_acm_certificate_validation" "main" {
certificate_arn = aws_acm_certificate.main.arn
timeouts {
create = "5m"
}
depends_on = [aws_route53_record.cert_validation]
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
certificate_arn = aws_acm_certificate.main.arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main.arn
}
depends_on = [aws_acm_certificate_validation.main]
}
The listener explicitly depends on the validation resource, ensuring the certificate is ready before the listener tries to bind to it. Without this dependency, Terraform might attempt to create the listener before validation finishes, causing subtle timing issues.
The 5-minute timeout is usually sufficient for Route 53 propagation and AWS's backend validation. We've never hit this timeout in production unless Route 53 records were misconfigured.
Common mistakes
Forgetting create_before_destroy: Without this lifecycle rule, certificate rotation will destroy the old certificate before the new one validates, causing downtime. Always include it.
Missing explicit dependencies: Relying on implicit dependencies (having Route 53 records as attributes) sometimes fails. Use explicit depends_on for clarity and reliability.
Using email validation in automation: Email validation requires manual action, breaking CI/CD pipelines. DNS validation is strictly better for infrastructure as code.
Forgetting us-east-1 for CloudFront: This is a production incident waiting to happen. If you use CloudFront, your ACM certificate must be in us-east-1.
Not setting appropriate TTLs on validation records: DNS propagation can be slow; set the TTL low (60 seconds) on validation records to ensure rapid updates if validation fails on the first try.
Key takeaways
- Always use DNS validation via Route 53 for production ACM certificates in Terraform; it integrates seamlessly and eliminates manual steps.
- Wildcard certificates reduce operational overhead; a single certificate covers unlimited subdomains at the same level.
- CloudFront certificates must live in us-east-1 regardless of your primary region; use provider aliases to manage this cleanly.
- Explicitly depend on
aws_acm_certificate_validationbefore attaching certificates to load balancers or CDNs; don't assume the ARN's existence means readiness. - Include
create_before_destroyin the certificate lifecycle to prevent downtime during renewal. - Set low TTLs on Route 53 validation records to speed up propagation and catch validation issues faster.






Comments
Loading comments…