• Terraform: AWS ACM Certificates for Multiple Domains

    My life got better when AWS introduced Certificate Manager, their service for issuing validated TLS certificates for consumption directly by other AWS services. You don’t get to download certificates issued by ACM to install on your own servers, but you can use them with your EC2 Load Balancers, CloudFront and some other services, alleviating the need to upload certificates and renew them since ACM renews them automatically.

    Closing the loop on automated certificates however, was still difficult since domain validation was done through verification emails. In Nov 2017 ACM started supporting DNS validation, which is especially great if your DNS resides on Route53. Looking to drive this combination with a single workflow, I looked at Terraform and happily enough, it supports all requisite services to make this happen. Let’s take a look.

    resource "aws_acm_certificate" "main" {
      domain_name = "example.net"
      subject_alternative_names = ["*.example.net"]
      validation_method = "DNS"
      tags {
        Name = "example.net"
        terraform = "true"
      }
    }
    
    data "aws_route53_record" "validation" {
      name = "example.net."
    }
    
    resource "aws_route53_record" "validation" {
      name = "${aws_acm_certificate.main.domain_validation_options[0].resource_record_name}"
      type = "${aws_acm_certificate.main.domain_validation_options[0].resource_record_type}"
      zone_id = "${data.aws_route53_zone.validation.zone_id}"
      records = ["${aws_acm_certificate.main.domain_validation_options[0].resource_record_value}"]
      ttl = 60
    }
    
    resource "aws_acm_certificate_validation" "main" {
      certificate_arn = "${aws_acm_certificate.main.arn}"
      validation_record_fqdns = ["${aws_route53_record.validation.*.fqdn}"]
    }

    In the basic workflow of a wildcard certificate for a single domain, Terraform first requests a certificate, then creates validation records in DNS using the zone it looked up, then goes back to ACM to request validation. Importantly, Terraform then waits for the validation to complete before continuing, a crucial point that makes it possible to immediately start using this certificate elsewhere with Terraform without racing against the validation process.

    This is pretty great, but it’s not yet portable, and what if we want to exploit all 10 (yes, ten) subjectAlternativeNames that ACM offers us?

    I toyed with this for some time, getting angry and then sad, but eventually elated, at Terraform’s interpolation functions, until I came up with this (excerpt from a working Terraform module):

    variable "domain_names" { type = "list" }
    variable "zone_id" {}
    
    resource "aws_acm_certificate" "main" {
      domain_name = "${var.domain_names[0]}"
      subject_alternative_names = "${slice(var.domain_names, 1, length(var.domain_names))}"
      validation_method = "DNS"
      tags {
        Name = "${var.domain_names[0]}"
        terraform = "true"
      }
    }
    
    resource "aws_route53_record" "validation" {
      count = "${length(var.domain_names)}"
      name = "${lookup(aws_acm_certificate.main.domain_validation_options[count.index], "resource_record_name")}"
      type = "${lookup(aws_acm_certificate.main.domain_validation_options[count.index], "resource_record_type")}"
      zone_id = "${var.zone_id}"
      records = ["${lookup(aws_acm_certificate.main.domain_validation_options[count.index], "resource_record_value")}"]
      ttl = 60
    }
    
    resource "aws_acm_certificate_validation" "main" {
      certificate_arn = "${aws_acm_certificate.main.arn}"
      validation_record_fqdns = ["${aws_route53_record.validation.*.fqdn}"]
    }
    
    output "arn" {
      value = "${aws_acm_certificate.main.arn}"
    }

    Use this as a module like this:

    module "acm_ops" {
      source = "modules/aws_acm_certificate"
      domain_names = ["ops.acme.net", "*.ops.acme.net"]
      zone_id = "${aws_route53_zone.external.id}"
    }
    
    module "acm_marketing" {
      source = "modules/aws_acm_certificate"
      domain_names = ["acme.com", "*.acme.com"]
      zone_id = "${aws_route53_zone.acme.id}"
    }

    The module accepts a list of domain names and a Route53 zone ID, and will generate a unified validated certificate, returning the ARN of the certificate which you can then use with your ELB or CloudFront resources. Peeking inside, this makes use of lookup() and splatting to parse the validation options and create all the necessary DNS records.

    The full source code for this module is available on GitHub.