Securing a site with Letsencrypt, AWS and Terraform

Dani Hodovic March 29, 2018 5 min read
Responsive image

Update August 2019

This post was written at a time when AWS certificates took forever to generate. Nowadays the process is much faster, so use ACM if you're already on AWS. ACM also automatically renews your certificates.

Introduction

Google Chrome will start marking HTTP sites as 'not secure' by July 2018. This is as good time as ever to secure our sites. If you happen to be deploying your site on AWS and provision your infrastructure using Terraform there is a simple way to generate certificates using Terraform and LetsEncrypt.

LetsEncrypt is an open and free Certificate Authority (CA) provided by the Internet Security Research Group (ISRG). LetsEncrypt provides software tools to automatically generate TLS certificates and verifies said certificates. Certbot is a client side tool developed by LetsEncrypt that allows a developer to automatically generate a TLS certificate for a domain.

Terraform is a tool for provisioning infrastructure. It allows us to describe our infrastructure resources as code and execute it to create our infrastructure in the cloud.

AWS already provides a way to generate SSL certificates by using their CA. [AWS ACM] (https://aws.amazon.com/certificate-manager/) integrates nicely with existing AWS services. It's a good option if you're already running on AWS. However when I was using it I found that the time to create and validate a certificate using ACM was very slow. Usually it would take between 30-60 minutes for me to validate a certificate which is too long for me.

On the flip side LetsEncrypt also integrates nicely with AWS using DNS validation . By creating route53 records using the certbot DNS plugin we can generate wildcard certificates for our domain and all of the subdomains. I found validation using LetsEncrypt to be much faster than AWS ACM. The validation process takes less than a minute and generates certificates to your local machine.

The benefit AWS ACM has over LetsEncrypt is that it will automatically renew your certificates as long as they're being actively used by your domain and an AWS service, such as a ELB or Cloudfront.

Implementation

Creating our Route53 hosted zone

First of all we will need to create a route53 hosted zone. This is where the A records for our domain will reside and also where certbot will create the DNS TXT records.

variable "domain" {
  type = "string"
}

resource "aws_route53_zone" "main" {
  name = "${var.domain}"
}

output "name_servers" {
  value = "${aws_route53_zone.main.name_servers}"
}

We also need to provide variables for Terraform in a terraform.tfvars file.

domain = "mydomain.com"

Run terraform to create the hosted zone

terraform apply -auto-approve

At this point we need to go to our registrar and change the authoritative DNS Name Servers for our domain. Retrieve the name servers from our new route53 zone using Terraform output:

$ terraform output name_servers
ns-1153.awsdns-16.org,
ns-170.awsdns-21.com,
ns-1942.awsdns-50.co.uk,
ns-664.awsdns-19.net

Now take these NS records and enter them as the authoritative nameservers for your domain in your registrar. These changes can take from minutes to days to propagate, but for domains I have purchased at Namecheap, Register365 and Route53 it has usually only taken a few minutes.

To ensure that our domain is using our Route53 name servers you can run the whois command that's available on most platforms.

$ whois danihodovic.com | grep 'Name Server'
   Name Server: NS-1133.AWSDNS-13.ORG
   Name Server: NS-1849.AWSDNS-39.CO.UK
   Name Server: NS-443.AWSDNS-55.COM
   Name Server: NS-858.AWSDNS-43.NET

Generating our certificate

We're going to use LetsEncrypt with the Route 53 plugin. to generate a TLS certificate on our local machine.

LetsEncrypt's client software, Certbot, will create a TXT record with a random token in our hosted zone. It will proceed to tell LetsEncrypt to query the automotive servers for our domain. LetsEncrypt will look for the TXT records Certbot created in our domain and find the random token which proves ownership of the domain.

Certbot requires AWS credentials and permissions to create route53 records. An example of what IAM policies are required can be found here. If you're using AWS credentials with administrator access you don't have to worry about IAM.

It's time to run Certbot and generate the certificate. I've created a Dockerfile which packages Certbot and the Route53 DNS plugin so that I don't have to install it on my local machine. If you're already using Docker you can run the following command.

docker run \
    --rm \
    -v "/etc/letsencrypt:/etc/letsencrypt/" \
    -v "$HOME/.aws/:/root/.aws:ro" \
    -e AWS_PROFILE \
    -e AWS_ACCESS_KEY_ID \
    -e AWS_SECRET_ACCESS_KEY \
    danihodovic/certbot-route53 -d 'mydomain.com' -d '*.mydomain.com' -m [email protected]

Here is the non-docker version (Ubuntu is assumed to be the distribution).

# Install certbot
sudo add-apt-repository ppa:certbot/certbot
sudo apt-get update
sudo apt-get install certbot

# Install the route53 plugin
sudo apt-get install python-pip
sudo pip install certbot-dns-route53
certbot certonly \
    -n \
    --agree-tos \
    --email [email protected] \
    --dns-route53 \
    -d 'mydomain.com' \
    -d '*.mydomain.com'

Creating an aws_iam_certificate resource

Now we'll upload the certificate LetsEncrypt generated using the aws_iam_server_certificate resource in Terraform. This allows other AWS resources, such as ELBs and Cloudfront Distributions to use our certificate to encrypt traffic between end users and the ELB or Cloudfront.

We'll use three new variables for the IAM certificate. These are paths to the files created by Certbot.

  • ssl_cert_file_path - the path to the certificate file. Certbot places this in /etc/letsencrypt/live/mydomain.com/cert.pem
  • ssl_private_key_file_path - the path to the private key. Certbot places this in /etc/letsencrypt/live/mydomain.com/privkey.pem
  • ssl_certificate_chain_file_path - the path to the certificate chain. Certbot places this in /etc/letsencrypt/live/mydomain.com/chain.pem
variable "ssl_cert_file_path" {
  type = "string"
}

variable "ssl_private_key_file_path" {
  type = "string"
}

variable "ssl_certificate_chain_file_path" {
  type = "string"
}

resource "aws_iam_server_certificate" "cert" {
  name_prefix       = "${var.domain}"
  certificate_body  = "${file(pathexpand(var.ssl_cert_file_path))}"
  private_key       = "${file(pathexpand(var.ssl_private_key_file_path))}"
  certificate_chain = "${file(pathexpand(var.ssl_certificate_chain_file_path))}"

  # Some properties of an IAM Server Certificates cannot be updated while they
  # are in use. In order for Terraform to effectively manage a Certificate in
  # this situation, it is recommended you utilize the name_prefix attribute and
  # enable the create_before_destroy lifecycle block.
  lifecycle {
    create_before_destroy = true
  }
}

We'll populate these variables in terraform.tfvars.

domain                          = "mydomain.com"
ssl_cert_file_path              = "/etc/letsencrypt/live/mydomain.com/cert.pem"
ssl_private_key_file_path       = "/etc/letsencrypt/live/mydomain.com/privkey.pem"
ssl_certificate_chain_file_path = "/etc/letsencrypt/live/mydomain.com/chain.pem"

Using our aws_iam_certificate

We can now use the IAM certificate to encrypt traffic between our load balancer and our visitors.

resource "aws_elb" "main" {
  listener {
    instance_port     = 80
    instance_protocol = "http"
    lb_port           = 80
    lb_protocol       = "http"
  }

  listener {
    instance_port      = 443
    instance_protocol  = "http"
    lb_port            = 443
    lb_protocol        = "https"
    ssl_certificate_id = "${aws_iam_server_certificate.cert.arn}"
  }

  security_groups = ["${aws_security_group.elb.id}"]
}

The ELB also needs a security group that allows for incoming and outgoing traffic.

resource "aws_security_group" "elb" {
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 65535
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

It's a good idea to backup the certificates generated in /etc/letsencrypt on your local machine incase your hard disk fails, but I won't be covering that in this post.