IaC AWS

Terraform Module: AWS ACM Certificate — DNS-validated TLS that issues itself

Quick take — A reusable Terraform module for AWS ACM that requests a public certificate, emits the DNS validation records, and waits for issuance — SANs, Route 53 auto-validation, and CloudFront/ALB-ready outputs included. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "acm" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-acm?ref=v1.0.0"

  domain_name = "..."  # Primary FQDN for the certificate (e.g. `example.com` or…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

AWS Certificate Manager (ACM) issues and renews public X.509 TLS certificates for free, as long as you can prove you control the domain. The catch is that a certificate is never just one resource. A real-world ACM workflow is three coordinated steps: request the certificate (aws_acm_certificate), publish the DNS validation CNAME records that ACM hands back (aws_route53_record), and then block until ACM observes those records and flips the certificate to ISSUED (aws_acm_certificate_validation). Get the wiring wrong — apply the certificate before the records exist, or hand a not-yet-validated ARN to CloudFront — and your terraform apply either hangs for the full validation timeout or fails downstream because the cert isn’t usable yet.

Wrapping this in a module collapses that dance into a single, var-driven call. You pass a primary domain and optional SANs; the module requests the cert, fans the validation records into Route 53, waits for issuance, and exposes a certificate_arn that is guaranteed to be ISSUED by the time anything reads it. It also pins the cert to DNS validation (the only method that supports automatic renewal without manual intervention), enables a create_before_destroy lifecycle so in-place domain changes don’t drop your TLS, and standardises tagging — so every certificate in the estate is consistent instead of being a hand-clicked snowflake from the console.

When to use it

Reach for a different approach when DNS is outside Route 53 (you’ll consume the domain_validation_options output and create records in your own provider), when you need a private certificate from ACM PCA (that’s certificate_authority_arn, a different cost and trust model), or when you’re importing an externally-issued cert (use aws_acm_certificate with private_key/certificate_body instead — this module is built for ACM-issued, DNS-validated public certs).

Module structure

terraform-module-aws-acm/
├── versions.tf      # provider + Terraform version constraints
├── main.tf          # certificate, Route 53 validation records, validation wait
├── variables.tf     # domain, SANs, validation toggles, tags
└── outputs.tf       # certificate_arn, domain, status, validation options

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Tags applied to every certificate this module creates.
  common_tags = merge(
    var.tags,
    {
      Name      = var.domain_name
      ManagedBy = "terraform"
      Module    = "terraform-module-aws-acm"
    },
  )

  # When DNS validation runs through Route 53, ACM returns one validation
  # record per distinct (domain, SAN) name. Deduplicate by record name so
  # overlapping SANs (e.g. example.com + *.example.com share a name) don't
  # collide into duplicate resources.
  validation_records = var.validation_method == "DNS" && var.create_route53_records ? {
    for dvo in aws_acm_certificate.this.domain_validation_options :
    dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  } : {}
}

resource "aws_acm_certificate" "this" {
  domain_name               = var.domain_name
  subject_alternative_names = var.subject_alternative_names
  validation_method         = var.validation_method

  # EC keys (e.g. EC_prime256v1) are smaller/faster on the TLS handshake;
  # RSA_2048 is the broadest-compatibility default.
  key_algorithm = var.key_algorithm

  # Required for CloudFront/edge certs; harmless elsewhere. Default null lets
  # ACM fall back to the standard 13-month validity.
  certificate_authority_arn = var.certificate_authority_arn

  options {
    certificate_transparency_logging_preference = var.transparency_logging ? "ENABLED" : "DISABLED"
  }

  tags = local.common_tags

  # Replace the cert before destroying the old one so swapping a domain or
  # adding a SAN never leaves a listener/distribution without a valid cert.
  lifecycle {
    create_before_destroy = true
  }
}

# One CNAME per validation challenge, written into the caller-supplied
# Route 53 hosted zone. Skipped entirely when create_route53_records = false
# (e.g. DNS lives elsewhere — consume the domain_validation_options output).
resource "aws_route53_record" "validation" {
  for_each = local.validation_records

  zone_id         = var.route53_zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = var.validation_record_ttl
  allow_overwrite = var.allow_validation_record_overwrite
}

# Blocks until ACM marks the certificate ISSUED. Only created when we own the
# validation records; otherwise downstream should depend on the cert ARN
# directly. The validation_record_fqdns wiring makes the wait depend on the
# CNAMEs actually existing first.
resource "aws_acm_certificate_validation" "this" {
  count = var.validation_method == "DNS" && var.create_route53_records ? 1 : 0

  certificate_arn         = aws_acm_certificate.this.arn
  validation_record_fqdns = [for r in aws_route53_record.validation : r.fqdn]

  timeouts {
    create = var.validation_timeout
  }
}

variables.tf

variable "domain_name" {
  description = "Primary fully-qualified domain name for the certificate (e.g. example.com or *.example.com)."
  type        = string

  validation {
    condition     = length(var.domain_name) > 0 && length(var.domain_name) <= 253
    error_message = "domain_name must be a non-empty FQDN of 253 characters or fewer."
  }
}

variable "subject_alternative_names" {
  description = "Additional SANs to include on the certificate (e.g. [\"*.example.com\", \"app.example.com\"]). The primary domain_name is added automatically."
  type        = list(string)
  default     = []
}

variable "validation_method" {
  description = "How ACM verifies domain ownership. DNS supports automatic renewal; EMAIL does not and cannot be automated."
  type        = string
  default     = "DNS"

  validation {
    condition     = contains(["DNS", "EMAIL"], var.validation_method)
    error_message = "validation_method must be either \"DNS\" or \"EMAIL\"."
  }
}

variable "key_algorithm" {
  description = "Key algorithm for the certificate. RSA_2048 is most compatible; EC_prime256v1 is faster on the handshake."
  type        = string
  default     = "RSA_2048"

  validation {
    condition     = contains(["RSA_2048", "RSA_3072", "RSA_4096", "EC_prime256v1", "EC_secp384r1"], var.key_algorithm)
    error_message = "key_algorithm must be one of RSA_2048, RSA_3072, RSA_4096, EC_prime256v1, or EC_secp384r1."
  }
}

variable "certificate_authority_arn" {
  description = "ARN of an ACM Private CA to issue from. Leave null for a free public certificate."
  type        = string
  default     = null
}

variable "transparency_logging" {
  description = "Whether to log issuance to public Certificate Transparency logs. Must stay true for publicly-trusted certs; only disable for private CA certs you do not want publicly visible."
  type        = bool
  default     = true
}

variable "create_route53_records" {
  description = "Create the DNS validation CNAMEs in Route 53 and wait for issuance. Set false when authoritative DNS is not in Route 53."
  type        = bool
  default     = true
}

variable "route53_zone_id" {
  description = "Route 53 hosted zone ID that owns the domain. Required when create_route53_records is true."
  type        = string
  default     = null

  validation {
    condition     = var.route53_zone_id == null || can(regex("^Z[A-Z0-9]+$", var.route53_zone_id))
    error_message = "route53_zone_id must be a valid Route 53 zone ID starting with Z, or null."
  }
}

variable "validation_record_ttl" {
  description = "TTL (seconds) for the DNS validation CNAME records."
  type        = number
  default     = 60
}

variable "allow_validation_record_overwrite" {
  description = "Allow Terraform to overwrite an existing validation record of the same name. Useful when SANs share zones or on re-issuance."
  type        = bool
  default     = true
}

variable "validation_timeout" {
  description = "Maximum time to wait for ACM to mark the certificate ISSUED (Go duration string, e.g. \"45m\")."
  type        = string
  default     = "45m"
}

variable "tags" {
  description = "Tags to apply to the certificate, merged with module-managed defaults."
  type        = map(string)
  default     = {}
}

outputs.tf

output "certificate_arn" {
  description = "ARN of the certificate. When Route 53 validation runs, this is the validated ARN (guaranteed ISSUED); otherwise the requested ARN."
  value = var.validation_method == "DNS" && var.create_route53_records ? (
    aws_acm_certificate_validation.this[0].certificate_arn
  ) : aws_acm_certificate.this.arn
}

output "certificate_id" {
  description = "The ARN/ID of the underlying aws_acm_certificate resource (unconditional, even before validation)."
  value       = aws_acm_certificate.this.id
}

output "domain_name" {
  description = "Primary domain name on the certificate."
  value       = aws_acm_certificate.this.domain_name
}

output "subject_alternative_names" {
  description = "All subject alternative names on the issued certificate."
  value       = aws_acm_certificate.this.subject_alternative_names
}

output "status" {
  description = "Certificate status reported by ACM (e.g. ISSUED, PENDING_VALIDATION)."
  value       = aws_acm_certificate.this.status
}

output "domain_validation_options" {
  description = "Validation challenge records (name/type/value). Consume these to create DNS records yourself when create_route53_records = false."
  value       = aws_acm_certificate.this.domain_validation_options
}

How to use it

A wildcard certificate for CloudFront must live in us-east-1, so we pass an aliased provider into the module. Once issued, the validated ARN feeds straight into the distribution’s viewer certificate.

# CloudFront only trusts certificates from us-east-1.
provider "aws" {
  alias  = "use1"
  region = "us-east-1"
}

data "aws_route53_zone" "primary" {
  name         = "kloudvin.com"
  private_zone = false
}

module "acm_certificate" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-acm?ref=v1.0.0"

  providers = {
    aws = aws.use1
  }

  domain_name = "kloudvin.com"
  subject_alternative_names = [
    "*.kloudvin.com",
  ]

  route53_zone_id    = data.aws_route53_zone.primary.zone_id
  key_algorithm      = "EC_prime256v1"
  validation_timeout = "30m"

  tags = {
    Environment = "production"
    Project     = "kloudvin-edge"
    CostCenter  = "platform"
  }
}

# Downstream: the distribution can only use an ISSUED cert, and
# certificate_arn resolves to the validated ARN — so CloudFront waits
# implicitly for ACM to finish.
resource "aws_cloudfront_distribution" "site" {
  # ... origins, default_cache_behavior, etc. ...
  aliases = ["kloudvin.com", "www.kloudvin.com"]

  viewer_certificate {
    acm_certificate_arn      = module.acm_certificate.certificate_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/acm/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-acm?ref=v1.0.0"
}

inputs = {
  domain_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/acm && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
domain_name string Yes Primary FQDN for the certificate (e.g. example.com or *.example.com).
subject_alternative_names list(string) [] No Additional SANs; the primary domain is added automatically.
validation_method string "DNS" No DNS (auto-renewable) or EMAIL.
key_algorithm string "RSA_2048" No One of RSA_2048, RSA_3072, RSA_4096, EC_prime256v1, EC_secp384r1.
certificate_authority_arn string null No ACM Private CA ARN; leave null for a free public cert.
transparency_logging bool true No Enable Certificate Transparency logging (keep true for public certs).
create_route53_records bool true No Create validation CNAMEs in Route 53 and wait for issuance.
route53_zone_id string null Conditionally Hosted zone ID; required when create_route53_records is true.
validation_record_ttl number 60 No TTL (seconds) for the validation CNAME records.
allow_validation_record_overwrite bool true No Allow overwriting an existing validation record of the same name.
validation_timeout string "45m" No Max wait for ACM to mark the cert ISSUED (Go duration).
tags map(string) {} No Tags merged with module-managed defaults.

Outputs

Name Description
certificate_arn Certificate ARN; the validated (ISSUED) ARN when Route 53 validation runs, otherwise the requested ARN.
certificate_id ID/ARN of the underlying aws_acm_certificate resource, available before validation completes.
domain_name Primary domain name on the certificate.
subject_alternative_names All subject alternative names on the issued certificate.
status ACM-reported status (e.g. ISSUED, PENDING_VALIDATION).
domain_validation_options Validation challenge records to consume when creating DNS records outside Route 53.

Enterprise scenario

A SaaS platform team runs a multi-tenant product where each customer gets a vanity subdomain under *.app.acme-cloud.com, fronted by a shared CloudFront distribution. They call this module once in their us-east-1 edge stack to mint the wildcard certificate, pinned to EC_prime256v1 for faster handshakes at the edge, with validation records written automatically into the central Route 53 zone. Because the module waits on aws_acm_certificate_validation, their pipeline never ships a distribution pointing at a half-issued cert, and ACM’s automatic DNS renewal means the platform team has not touched a TLS certificate by hand in two years.

Best practices

TerraformAWSACM CertificateModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading