5 Minute Static SSL Website in AWS with Terraform

Sage McEnery
Modern Stack
Published in
12 min readNov 5, 2017

One of the chores I find myself regularly doing when working on a new project is creating static HTML front-end website’s for the Serverless backend’s I work on. While the process of creating these sites is super simple on its own, I decided I would automate the entire process so I could “push a button” on my next project and deploy a functioning static website up in AWS. Since SSL certs are free on AWS, I decided I would also fold in SSL certificate creation.

We are going to accomplish this task today using a tool called Terraform, from Hashicorp. If you haven’t heard of Terraform yet, it is a sweet little tool that allows you to define your Infrastructure as Code (there is that buzzword) in a language called HCL (Hashicorp Configuration Language) which is quite similar to JSON. We will use terraform to develop a “plan” which will describe the infrastructure we want to create.

I will be using Visual Studio Code for all development that will happen. The source code from this article can be found here.

Hopefully, if all goes well, it will take you at least twice as long to read this article as it will to employ this solution in your own environment.

Laying the groundwork for our project

One of the first tasks we need to complete is to lay out our terraform project. A terraform project can take many forms, but the basic file\folder structure of a terraform project usually takes on the following shape;

Basic terraform project structure

Where main.tf is a file containing our basic configuration plan and vars.tf is a file containing terraform variables.

The next thing we would want to do is define our global variables. These variables will be used throughout our terraform plan. Variables in terraform take on the following shape;

variable "variable_name" {
default = "asdf" # optional, use to provide static value
description = "asdf" # optional, use when prompting for value
}

For our purposes, we need to know 4 separate pieces of information;

  1. the AWS Region we want to deploy to
  2. the AWS Access Key
  3. the AWS Access Secret
  4. the DNS Domain name for the Site we want to deploy.

The first three Variables are going to be used to configure our Provider. Here is how our vars.tf file looks after creating these attributes;

Terraform Variables File

Right off the bat, we should address the fact that this file is not that easy on the eyes. For instance, there is a missing quote of line 8 which doesn’t stand out very well. If terraform is supposed to make our lives easier, it would be helpful if our IDE could make working with terraform files easier as well. Luckily, there are a few marketplace extensions we can quickly install to address this shortcoming.

Terraform Extensions available in the VSCode Marketplace

For now, I am just going to install the first extension to provide me with some syntax highlighting and formatting assistance.

Variables, after installing Terraform Extensions

Much easier to read!

For each of the provider specific variables, we are supplying a default value. We are hardcoding these values because they would rarely change from plan to plan. Notice that for the “site_name” variable we only provided a description. The site_name variable value is subject to change based on the project and will be provided by us when we apply the configuration.

Supplying a Variable at RunTime.

An easier way to pass variables to a terraform plan (or apply) is via the following command line syntax;

terraform plan -var 'site_name=demo.example.com'

passing multiple variables can be done as well using the following syntax;

terraform plan -var 'site_name=demo.example.com' -var "dns_ttl=30"

Planning our AWS Configuration!

To accomplish our goal of setting up a static HTTPS website hosted in AWS, we are going to need to figure out which AWS features we will be leveraging.

For this project, we are going to utilize the following AWS features;

  1. ACM Certificate Services
  2. S3 — Simple Storage Services
  3. Route53 DNS Services
  4. Cloudfront CDN Services

S3 will host our static Website and we will enable the web hosting feature on our bucket. Route53 will be used to manage the DNS simply because it is cleaner to keep everything under one roof. Normally I just leverage GoDaddy DNS since they are also my registrar, but there is currently no terraform provider for GoDaddy. ACM is used to issue & supply our SSL certificates. Cloudfront, which is basically a CDN, allows us to serve our S3 static website as an HTTPS resource.

Scripting our Terraform Plan

If you have not seen an HCL script before, there a few things that might jump out at you. The first and most important syntax to take note of is Interpolation Syntax;

# Examples of Interpolation Syntax
"${var.site_name}"
"${var.site_name}-origin"
"${data.external.cert_request.result.CertificateArn}"
# For Example, we defined an S3 bucket like this;
resource "aws_s3_bucket" "site_bucket" {- outputs bucket_domain_name
# we can access that output value using interpolation
"${aws_s3_bucket.site_bucket.bucket_domain_name}"

Our initial terraform configuration

# Issue a CLI call to get a cert. Re-requests just return the ARN
data "external" "cert_request" {
program = ["bash", "./req_cert.sh"]
query = {
site_name = "${var.site_name}"
}
}
# s3 Bucket with Website settings
resource "aws_s3_bucket" "site_bucket" {
bucket = "${var.site_name}"
acl = "public-read"
website {
index_document = "index.html"
error_document = "error.html"
}
}
# Route53 Domain Name & Resource Records
resource "aws_route53_zone" "site_zone" {
name = "${var.site_name}"
}
resource "aws_route53_record" "site_cname" {
zone_id = "${aws_route53_zone.site_zone.zone_id}"
name = "${var.site_name}"
type = "NS"
ttl = "30"
records = [
"${aws_route53_zone.site_zone.name_servers.0}",
"${aws_route53_zone.site_zone.name_servers.1}",
"${aws_route53_zone.site_zone.name_servers.2}",
"${aws_route53_zone.site_zone.name_servers.3}"
]
}

# cloudfront distribution
resource "aws_cloudfront_distribution" "site_distribution" {
origin {
domain_name = "${aws_s3_bucket.site_bucket.bucket_domain_name}"
origin_id = "${var.site_name}-origin"
}
enabled = true
aliases = ["${var.site_name}"]
price_class = "PriceClass_100"
default_root_object = "index.html"
default_cache_behavior {
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH",
"POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "${var.site_name}-origin"
forwarded_values {
query_string = true
cookies {
forward = "all"
}
}
viewer_protocol_policy = "https-only"
min_ttl = 0
default_ttl = 1000
max_ttl = 86400
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = "${data.external.cert_request.result.CertificateArn}"
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.1_2016" # defaults wrong, set
}
}

You might notice that we are using the External Data provider to get our SSL cert. Currently, there is no terraform module which can request an SSL Cert from AWS because this process happens out of band. One way to get around this asynchronous process is to use this provider to issue an AWS CLI command to request the cert, then return the ARN data from that CLI call to our terraform plan.

The script the provider is executing, which could probably be simplified, is as follows;

#!/bin/bash# Extract the "site_name" variable from input JSON
eval "$(jq -r '@sh "domain=\(.site_name)"')"
# issue a CLI to request our cert
cert=$(aws acm request-certificate --domain-name $domain)
echo $cert > out.txt # output the response to a local file
# open the local file & pass to jq so we can extract the arn
certarn=$(cat out.txt | jq -r ".CertificateArn")
# output the arn so we can inpect if we want to
echo $certarn >> out.txt
# return a valid JSON document so we can reference the arn later
jq -n --arg cert_arn "$certarn" '{"CertificateArn":$cert_arn}'

Testing and Applying our Configuration

After you have defined all of your configuration settings in terraform, you should be ready to begin trying to apply it. Begin by opening a terminal or command line and cd-ing into the directory where your main.tf file is located.

A Terraform configuration is usually enacted by performing 3 actions in sequence; init, plan, & apply.

terraform init

The first thing you want to do is issue a terraform init command. Init will cause terraform to parse your config files for basic syntactical correctness and load whatever providers, provisioners & modules you have defined.

Successful Terraform Init — lists Providers used in Plan
Terraform Init with a Syntax Error

terraform plan

As you are developing and testing your terraform plan, you will usually issue a terraform plan command to validate your configuration. With plan, terraform will process your configuration and determine what steps would be needed to create the infrastructure with your configured provider. If there are obvious coding errors in your configuration, they will result in errors during the planning phase.

terraform apply

When you have completed developing your configuration and are ready to run with it, you will issue a terraform apply command. Assuming terraform plan did not generate any errors, odds are usually pretty good that apply will run without error as well. You may occasionally encounter errors running apply that did not show up during the plan phase. Terraform is pretty good about providing sufficient information in the console to point you towards the error in your configuration.

SSL Certificate Issuance is an out of band process.

Speaking of errors, you may encounter the following error when attempting to bind the SSL Cert to your Cloudfront distribution;

Error binding a Non-Issued cert to a Cloudfront Distribution.

The resolution to this issue is to simply open the email and Approve the Certificate Request, then re-run the terraform plan one more time.

Validating the Results

ACM — Certificate Management

The first thing we would want to check out is the issuance of our SSL certificate. We requested the SSL cert by using an external provider to issue a call to the AWS CLI, which starts the Certificate Issuance process. This process results in an Email being sent to the Administrative contact. This email has a link which must be clicked before Amazon issues the SSL Cert. SSL Certs which are not in an Issued status can’t be bound to a Cloudfront distribution.

SSL Certificate Validation Email

S3 Bucket and Website

Next, we will spot check the S3 bucket to make sure it is configured correctly.

s3 bucket created as expected

Checking the Permissions tab, we see that this bucket does indeed have the expected permissions and nothing more.

Route53 DNS

When we check our Route53 configuration, we encounter one setup which appears to be problematic.

Improper Route53 Configuration

Our terraform configuration, as written, created an incorrect Zone in Route53. The demo. subdomain should’ve been added as a record in the root domain zone. We are going to have to correct this in our configuration.

Cloudfront Distribution

Inspecting the CloudFront Distribution

When we inspect our Cloudfront Distribution, we see that all our settings appear to be correct and in order. If your Cloudfront distribution is not correct, you may find it easier to configure a distribution manually first, then mimic the settings you desire in your terraform config after.

At this point, pretty much all of our configuration appears to be correct with the exception of our Route53 config. We are going to fix this issue by altering our terraform configuration script and then reapply the configuration.

Undoing our incorrect configuration

Before we alter our terraform script though, we are going to issue a terraform destroy command to tear down everything we just created. After teardown is complete, we will then alter our scripts and correct any issues we found. Once we have tested our new configuration changes, we will apply the plan again and inspect the results.

Depending on the type and amount of infrastructure your terraform configuration creates, the destroy process can take a while to complete. It is important to let this process complete before making code changes to your configuration.

Fix our Route53 DNS issue with a Terraform Module

To fix our Route53 issue, we effectively want to create a “function” that takes the “site_name” as input and returns just the root domain name. EG: if we pass demo.example.com, we want example.com as our value. Additionally, if we were to pass; site2.demo.example.com for our site_name, we still just want “example.com” as our value.

Our Route53 Zone should be created for the root domain name and within this zone, we want to create NS, A and CNAME records accordingly.

Unfortunately, as of the time of this writing, terraform does not offer much “function” functionality. Additionally, variables cannot have their value set to an interpolation. Given these two short-comings with terraform, we need to look elsewhere to solve this problem.

Luckily, we can achieve the desired outcome using some creative variable parsing within a terraform module. To create a new module, we simply create a new subfolder called “dns_domain” and add a main.tf file in that folder. Within the main.tf file, we enter the following code;

# module definition for "dns_domain"
variable "domain_parts" { # split(".",domain) to create a list
type = "list"
}
variable "parts_count" {} # ~= length(split(".",domain))output "root_dns" {
value = "${var.domain_parts[var.parts_count - 2]}.${var.domain_parts[var.parts_count - 1]}"
}

We use our module by referencing it within our main, main.tf file, like so;

module "dns_domain" {
source = "./dns_domain" # path to the module folder
domain_parts = "${split(".",var.site_name)}"
parts_count = "${length(split(".",var.site_name))}"
}

And setting the modules variables; domain_parts & parts_count.

We then update our Route53_Zone resource to use the output from the module;

resource "aws_route53_zone" "site_zone" {
name = "${module.dns_domain.root_dns}"
}

We will now run a terraform plan on our new configuration to see what the results should look like.

Module output with a 3 part domain name
Module output when passing a 4 part domain name

Revisiting our Route53 Records

Knowing that we didn’t exactly setup our DNS correctly, we will reevaluate all our DNS related configuration and adjust our scripts accordingly. After some review, this is what our DNS configuration ends up looking like;

# Route53 Domain Name
resource "aws_route53_zone" "site_zone" {
name = "${module.dns_domain.root_dns}"
}
resource "aws_route53_record" "site_ns" {
zone_id = "${aws_route53_zone.site_zone.zone_id}"
name = "${module.dns_domain.root_dns}"
type = "NS"
ttl = "30"
records = [
"${aws_route53_zone.site_zone.name_servers.0}",
"${aws_route53_zone.site_zone.name_servers.1}",
"${aws_route53_zone.site_zone.name_servers.2}",
"${aws_route53_zone.site_zone.name_servers.3}"
]
}
# Create a "static." cName to point to the s3 bucket
resource "aws_route53_record" "site_cname_static" {
zone_id = "${aws_route53_zone.site_zone.zone_id}"
name = "static.${module.dns_domain.root_dns}"
type = "CNAME"
ttl = "30"
records = [
"${aws_s3_bucket.site_bucket.bucket_domain_name}"
]
}
# Create a CName for our site_name and point to cloudfront
resource "aws_route53_record" "site_cname" {
zone_id = "${aws_route53_zone.site_zone.zone_id}"
name = "${var.site_name}"
type = "CNAME"
ttl = "30"
records = [
"${aws_cloudfront_distribution.site_distribution.domain_name}"
]
}

Applying our New & Improved Configuration

With all these changes in place, we will now re-apply our entire new configuration. To do that, we need to perform the following actions;

terraform init # re-run init so we load our new module
terraform plan -var 'site_name=www.example.com'
terraform apply -var 'site_name=www.example.com;

Within a few minutes, our terraform configuration should be applied successfully. Once complete, we will now check our Route53 configuration for correctness.

Route53 Configuration — Root DNS Zones
Route53 Zone configuration

Not only do we have our root zone configured correctly, but we can see that all our DNS records appear as expected as well. We have a “static.” record which we can use to directly access the website hosted in S3 and our “test.” domain name is pointing to our Cloudfront Configuration.

Our SSL Certificate has been created and is in an Issued status
Our CloudFront configuration is using the correct SSL Certificate

Success!!

All our infrastructure has been created exactly as we intended.

Terraform Destroy

As you have seen over the course of this short article, using Terraform, we can stand up significant amounts of infrastructure pretty quick and easy. In fact, it took longer to visually inspect the results of the terraform script in AWS than it did to script and execute the configuration.

Luckily, tearing down all the infrastructure a set of terraform scripts creates can also be quick and easy. To tear down all the infrastructure a terraform script creates, just issue a terraform destroy command, passing in the same exact arguments you used when creating the infrastructure in the first place.

Terraform Destroy tearing down all of our freshly created infrastructure.

There are a few caveats regarding terraform destroy. One of the more important one’s is; if you alter your terraform scripts after running terraform apply, but before running terraform destroy, you may get some inconsistencies or errors. As such, if you encounter an error when running terraform apply, just issue a terraform destroy and then proceed to adjusting your scripts.

--

--