Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 036 / 100

ACM certificates with Terraform: lessons from production

HTTPS with automatic renewal.

Expona Admin7 min read

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:

hcl
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:

hcl
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:

hcl
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:

hcl
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_validation before attaching certificates to load balancers or CDNs; don't assume the ARN's existence means readiness.
  • Include create_before_destroy in 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles