Skip to content

SSL termination with ALB, AWS Certificate Manager and terraform

Recent years letsencrypt been very popular as you could use it for free and automate installation and upgrade of your certificates, but if your infrastructure is deployed on AWS, you can now use AWS Certificate Manager for SSL termination.

Today I will show how you can automate the process of generation and validation of your SSL certificate with AWS Certificate Manager and terraform.

First thing first, for the full automation you will need control over your DNS server, as automatic cert validation requires CNAME record creation, if you don’t have one, you can still generate the cert, but validation will be manual, through email, meaning you still need to be hostmaster or have someone who is, so they can validate your certs by clicking the link in the email.

If your DNS is on Route53 then all good as I will be using it for our example. So let’s assume you have hosted zone, like in example, I have ifritltd.co.uk:

Lets create certificate now, using terraform:

resource "aws_acm_certificate" "cert" {
  domain_name       = "lb.ifritltd.co.uk"
  validation_method = "DNS"
}

resource "aws_route53_record" "cert_validation_dns_record" {
  name    = "${aws_acm_certificate.cert.domain_validation_options.0.resource_record_name}"
  type    = "CNAME"
  zone_id = "Z3N0PU2D61X0DL"
  records = ["${aws_acm_certificate.cert.domain_validation_options.0.resource_record_value}"]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "cert_validation" {
  certificate_arn         = "${aws_acm_certificate.cert.arn}"
  validation_record_fqdns = ["${aws_acm_certificate.cert.domain_validation_options.0.resource_record_name}"]
}

The code is very simple, it uses 3 resources:

1) We create cert with DNS validation method in aws_acm_certificate, meaning we will need to create CNAME with details provided by this certificate.
2) Then in aws_route53_record we create CNAME, using record name and value from cert and zone id of our hosted zone.
3) Finally we add validation work flow resource aws_acm_certificate_validation, which refers to cert and validation resources.

Let’s run this and see what happens:

terraform plan -out=out.tfplan         
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.


------------------------------------------------------------------------

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  + aws_acm_certificate.cert
      id:                          <computed>
      arn:                         <computed>
      domain_name:                 "lb.ifritltd.co.uk"
      domain_validation_options.#: <computed>
      validation_emails.#:         <computed>
      validation_method:           "DNS"

  + aws_acm_certificate_validation.cert_validation
      id:                          <computed>
      certificate_arn:             "${aws_acm_certificate.cert.arn}"
      validation_record_fqdns.#:   <computed>

  + aws_route53_record.cert_validation_dns_record
      id:                          <computed>
      allow_overwrite:             "true"
      fqdn:                        <computed>
      name:                        "${aws_acm_certificate.cert.domain_validation_options.0.resource_record_name}"
      records.#:                   <computed>
      ttl:                         "60"
      type:                        "CNAME"
      zone_id:                     "Z3N0PU2D61X0DL"

As you see terraform is going to create 2 new resources, once happy let’s apply:

terraform apply out.tfplan    
aws_acm_certificate.cert: Creating...
  arn:                         "" => "<computed>"
  domain_name:                 "" => "lb.ifritltd.co.uk"
  domain_validation_options.#: "" => "<computed>"
  validation_emails.#:         "" => "<computed>"
  validation_method:           "" => "DNS"
aws_acm_certificate.cert: Creation complete after 6s (ID: arn:aws:acm:eu-west-2:228426479489:cert...e/f3f9bc73-a356-418e-afeb-8f465456cc8e)
aws_acm_certificate_validation.cert_validation: Creating...
  certificate_arn:                    "" => "arn:aws:acm:eu-west-2:228426479489:certificate/f3f9bc73-a356-418e-afeb-8f465456cc8e"
  validation_record_fqdns.#:          "" => "1"
  validation_record_fqdns.2242018379: "" => "_05c166699537de4efa40c6286f9af331.lb.ifritltd.co.uk."
aws_route53_record.cert_validation_dns_record: Creating...
  allow_overwrite:    "" => "true"
  fqdn:               "" => "<computed>"
  name:               "" => "_05c166699537de4efa40c6286f9af331.lb.ifritltd.co.uk"
  records.#:          "" => "1"
  records.1532773270: "" => "_8a6f9937100a9642cf4bcfb127000eb6.acm-validations.aws."
  ttl:                "" => "60"
  type:               "" => "CNAME"
  zone_id:            "" => "Z3N0PU2D61X0DL"
aws_acm_certificate_validation.cert_validation: Still creating... (10s elapsed)
aws_route53_record.cert_validation_dns_record: Still creating... (10s elapsed)

aws_route53_record.cert_validation_dns_record: Creation complete after 1m3s (ID: Z3N0PU2D61X0DL__05c166699537de4efa40c6286f9af331.lb.ifritltd.co.uk._CNAME)

aws_acm_certificate_validation.cert_validation: Still creating... (3m0s elapsed)
aws_acm_certificate_validation.cert_validation: Still creating... (3m10s elapsed)
aws_acm_certificate_validation.cert_validation: Creation complete after 3m15s (ID: 2018-06-26 20:04:25 +0000 UTC)

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

As you can see validation can take a bit longer, the default timeout is 45 mins, so make sure you set some lower value if you don’t want your pipeline to take ages.

Now let’s see what changes in the console.

First in the the AWS acm console we can see cert is created but pending, at this stage you can’t assign it to anything.

If you click on it you can see CNAME details, if it was EMAIL validation it would show email addresses to whom email is sent, terraform code refers to same details when creating CNAME record.

Now if you check route53 console you can see record there:

And finally a bit later (while you were making yourself coffee) cert is issued.

Now we can start using it. For testing purposes I will do it through console.
So in EC2 console create application load balancer:

then in the Load Balancer Protocol choose HTTPS:

That is the protocol we will use to connect to lb from client.

in second stage add certificate which we just created, it will appear in the list if you choose cert from ACM:

Then set up target group, this is the EC2 instance load balancer will pass the request to:

You may have noticed that I used HTTP here rather than HTTPS, it means load balancer will perform SSL termination and from there the connection will continue using just HTTP, although, you can choose HTTPS as well, to make it even more secure, in that case you will need to set up SSL on the host as well. That could be achieved easily using certificate matching your host details.

Once target group is configured we need to register the actual targets, for this you will need a host with nginx(or whatever you are using) running on same port as configured per target group:


After some time host will become healthy:

Once load balancer is created check it’s status:

It may take some time before it becomes active, so you alternatively create it first and then configure the target group to save some time.

As you have noticed, I have created a cert for domain name lb.ifritltd.co.uk. That means once load balancer is created we will also need to create an alias in route53, matching certificate CN with load balancer alias.

Alias is AWS DNS extension and similar to CNAME, it is faster and free, and in a way better than CNAME. (There are other cool features like you can have alias for zone apex while using alias but I don’t want to go in too much details here)


If we query our lb by name we should get something like below, given you have configured ngix on the ec2 host:

 ~ curl -Iv https://lb.ifritltd.co.uk

* Rebuilt URL to: https://lb.ifritltd.co.uk/
*   Trying 52.56.85.162...
* TCP_NODELAY set
* Connected to lb.ifritltd.co.uk (52.56.85.162) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/cert.pem
  CApath: none
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Client hello (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256
* ALPN, server accepted to use h2
* Server certificate:
*  subject: CN=lb.ifritltd.co.uk
*  start date: Jun 26 00:00:00 2018 GMT
*  expire date: Jul 26 12:00:00 2019 GMT
*  subjectAltName: host "lb.ifritltd.co.uk" matched cert's "lb.ifritltd.co.uk"
*  issuer: C=US; O=Amazon; OU=Server CA 1B; CN=Amazon
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7f8ef3800400)
> HEAD / HTTP/2
> Host: lb.ifritltd.co.uk
> User-Agent: curl/7.54.0
> Accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS updated)!
< HTTP/2 200
HTTP/2 200
< date: Tue, 26 Jun 2018 22:57:33 GMT
date: Tue, 26 Jun 2018 22:57:33 GMT
< content-type: text/html
content-type: text/html
< content-length: 612
content-length: 612
< server: nginx/1.10.3 (Ubuntu)
server: nginx/1.10.3 (Ubuntu)
< last-modified: Tue, 26 Jun 2018 20:24:27 GMT
last-modified: Tue, 26 Jun 2018 20:24:27 GMT
< etag: "5b32a0fb-264"
etag: "5b32a0fb-264"
< accept-ranges: bytes
accept-ranges: bytes

And on nginx logs you can also see health check requests from lb:

 tail -f /var/log/nginx/access.log
172.31.31.178 - - [26/Jun/2018:22:57:59 +0000] "GET / HTTP/1.1" 200 396 "-" "ELB-HealthChecker/2.0"
172.31.47.206 - - [26/Jun/2018:22:58:19 +0000] "GET / HTTP/1.1" 200 396 "-" "ELB-HealthChecker/2.0"
172.31.31.178 - - [26/Jun/2018:22:58:29 +0000] "GET / HTTP/1.1" 200 396 "-" "ELB-HealthChecker/2.0"
172.31.25.54 - - [26/Jun/2018:23:02:36 +0000] "GET / HTTP/1.1" 200 396 "-" "ELB-HealthChecker/2.0"
172.31.44.25 - - [26/Jun/2018:23:02:57 +0000] "GET / HTTP/1.1" 200 396 "-" "ELB-HealthChecker/2.0"
172.31.25.54 - - [26/Jun/2018:23:03:06 +0000] "GET / HTTP/1.1" 200 396 "-" "ELB-HealthChecker/2.0"

Next time we will look at how to configure self signed SSL using nginx and openssl