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
- You terminate TLS on CloudFront, an Application Load Balancer, or API Gateway and want certificates managed as code with hands-off renewal.
- Your domain’s authoritative DNS lives in Route 53, so the module can write validation records for you (the fully automated path).
- You need wildcard or multi-SAN certificates (
example.com+*.example.com, or several distinct hostnames on one cert). - You’re issuing a CloudFront/edge certificate and must provision it in
us-east-1, separately from your workload region. - You want the apply to fail fast or wait deterministically rather than handing un-validated ARNs to downstream resources.
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 config — live/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 config — live/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
- Always use DNS validation for anything you renew. Email validation can’t be automated and forces a human to click a link every ~13 months; DNS validation lets ACM renew silently as long as the CNAME stays in Route 53 — never delete those records.
- Provision CloudFront certs in
us-east-1. CloudFront only accepts ACM certs from N. Virginia regardless of where your origin lives; pass an aliased provider into the module rather than fightingInvalidViewerCertificateerrors later. - Keep
create_before_destroyand depend oncertificate_arn, notcertificate_id. Wiring ALBs/CloudFront to the validated ARN guarantees Terraform won’t attach aPENDING_VALIDATIONcert, and the lifecycle rule means adding a SAN re-issues without a TLS outage. - Cost is in the blast radius, not the cert. Public ACM certificates are free, but a single wildcard cert on a shared distribution is a single point of failure — segment certificates per product or per environment so one bad re-issue can’t take down everything.
- Leave Certificate Transparency logging enabled for public certificates (browsers increasingly require CT); only disable it for private-CA certs whose hostnames you genuinely want kept out of public CT logs.
- Tag consistently and name by domain. The module sets
Name = domain_nameandManagedBy = terraformso certificates are searchable in the ACM console and attributable to a cost center — essential when an estate accumulates dozens of them.