Creating ACM certificates via CloudFormation is easy, but validation is tedious.

This template automates ACM certificate validation using a Lambda custom resource. The Lambda polls CloudFormation stack events for DNS validation details, creates the required records, and completes when ACM validates the certificate.

Description: Deploys an ACM certificate and DNS validation records

Parameters:
  zoneName:
    Type: String
  domainName:
    Type: String

Resources:
  certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref domainName
      SubjectAlternativeNames:
        - !Sub "*.${domainName}"
      ValidationMethod: DNS

  certValidationResource:
    Type: Custom::CertValidation
    Properties:
      ServiceToken: !GetAtt lambdaFunction.Arn
      HostedZoneName: !Ref zoneName
      StackName: !Ref 'AWS::StackName'

  lambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
              - lambda.amazonaws.com
          Action:
            - sts:AssumeRole
      Path: "/"
      Policies:
      - PolicyName: root
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
            Resource: '*'
          - Effect: Allow
            Action:
              - route53:ChangeResourceRecordSets
              - route53:ListHostedZonesByName
              - cloudformation:DescribeStackEvents
            Resource: '*'

  lambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.6
      Timeout: '300'
      Handler: index.handler
      Role: !GetAtt lambdaRole.Arn
      Code:
        ZipFile: |
          #!/usr/bin/env python3

          import cfnresponse
          import boto3
          import re
          import time


          def get_zone_id_from_name(zone_name):
              r53 = boto3.client('route53')
              r = r53.list_hosted_zones_by_name(DNSName=zone_name)
              for hz in r['HostedZones']:
                  return hz['Id']


          def get_stack_cert_event(stack_name):
              cfn = boto3.client('cloudformation')
              params = {'StackName': stack_name}
              event = None
              while True:
                  r = cfn.describe_stack_events(**params)
                  for e in r['StackEvents']:
                      if (
                          e['ResourceType'] == 'AWS::CertificateManager::Certificate' and
                          e['ResourceStatus'] == 'CREATE_IN_PROGRESS' and
                          'ResourceStatusReason' in e and
                          'Content of DNS Record' in e['ResourceStatusReason']
                      ):
                          event = e
                  if 'NextToken' in r:
                      params['NextToken'] = r['NextToken']
                  else:
                      break
              return event


          def parse_event(event):
              reason = event['ResourceStatusReason']
              m = re.search(r'\{Name\: (.+),\s?Type\: (.+),\s?Value\: (.+)\}', reason)
              if m:
                  dns = {
                      'Name': m.group(1),
                      'Type': m.group(2),
                      'Value': m.group(3)
                  }
                  return dns
              return None


          def upsert(hosted_zone_id, dns_record):
              r53 = boto3.client('route53')
              r53.change_resource_record_sets(
                  HostedZoneId=hosted_zone_id,
                  ChangeBatch={
                      'Changes': [{
                          'Action': 'UPSERT',
                          'ResourceRecordSet': {
                              'Name': dns_record['Name'],
                              'Type': dns_record['Type'],
                              'TTL': 60,
                              'ResourceRecords': [{
                                  'Value': dns_record['Value']
                              }]
                          }
                      }]
                  }
              )


          def handler(event, context):
              try:
                  if event['RequestType'] == 'Create':
                      cert_event = None
                      while cert_event is None:
                          cert_event = get_stack_cert_event(
                            event['ResourceProperties']['StackName'])
                          time.sleep(5)
                      dns_record = parse_event(cert_event)
                      if dns_record:
                          hosted_zone_id = get_zone_id_from_name(
                              event['ResourceProperties']['HostedZoneName'])
                          upsert(hosted_zone_id, dns_record)
              except Exception as e:
                  print(e)
              finally:
                  if 'Offline' not in event['ResourceProperties']:
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {})


          if __name__ == '__main__':
              event = {
                  'ResourceProperties': {
                      'HostedZoneName': 'x.x.x',
                      'Offline': True
                  }
              }
              handler(event, None)


Outputs:
  certificateArn:
    Value: !Ref certificate