Deploying a Wicked-Fast Static Site with Terraform, S3, Codebuild & Cloudfront

Highrise Scaffolding

How We Got Here #

I come from a background of trying to shoehorn functional programming into everything that I do. So naturally, the previous iteration of this site was a vastly overcomplex Clojure(Script) monorepo that served a client side rendered re-frame application. You can actually find the codebase here, if you're interested.

Don't get me wrong, I love re-frame and the Clojure ecosystem, but it meant I ended up creating a site that became far too irritating to turn into a proper blog. So, I made the decision to retire that site, and start fresh.

This leads me into what I wanted to learn from creating a new site.

Requirements #

Implementation #

S3 #

First, I wanted to create an S3 bucket that has website hosting capabilities. This is pretty easy using the aws_s3_bucket data source.

data "aws_iam_policy_document" "bucket_policy" {
statement {
sid = "PublicReadForGetBucketObjects"
actions = [
"s3:GetObject"
]
effect = "Allow"
resources = [
"arn:aws:s3:::${var.bucket}/*",
]
}
}

resource "aws_s3_bucket" "blog_bucket" {
bucket = "${var.bucket}"
region = "${var.region}"
acl = "public-read"
policy = "${data.aws_iam_policy_document.bucket_policy.json}"
website {
index_document = "index.html"
error_document = "404.html"
}

tags = {
Name = "${var.bucket}"
Environment = "${var.env}"
}
}

Here we enable our bucket as a website and allow public reads with a given policy.
If you were ok with not having a custom domain (with this configuration), you could build your static site (for my site, using the command hugo), and sync the generated static files with the s3 bucket using the aws cli on your local machine (for me, this would be aws s3 sync --sse --delete ./public/ s3://${bucket_name} where ${bucket_name} is the name of the generated S3 bucket).

But... this is a bit too easy for my liking. I don't want to have to build the site manually on my machine and sync it with the bucket afterwards (oh, the horror!). Thankfully AWS CodeBuild can solve this problem neatly for me.

CodeBuild #

Here is where things start to get a little bit tricky. I decided to base my approach from a Terraform module called terraform-aws-codebuild. However, I didn't need all of it's functionality so I spliced the bits that I needed and threw them directly into my own config.

resource "aws_iam_role" "build_role" {
name = "roryhow-blog-codebuild-role-${var.env}"
assume_role_policy = "${data.aws_iam_policy_document.role.json}"
}

data "aws_iam_policy_document" "role" {
statement {
sid = "WriteToS3ForCodeBuildService"

actions = [
"sts:AssumeRole",
]

principals {
type = "Service"
identifiers = ["codebuild.amazonaws.com"]
}

effect = "Allow"
}
}

resource "aws_iam_policy" "build_policy" {
name = "roryhow-blog-codebuild-policy-${var.env}"
path = "/service-role/"
policy = "${data.aws_iam_policy_document.permissions.json}"
}

data "aws_iam_policy_document" "permissions" {
statement {
sid = "WriteToS3ForCodeBuildService"

actions = [
"iam:PassRole",
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"ssm:GetParameters",
"s3:*"
]

effect = "Allow"

resources = [
"*",
]
}
}

resource "aws_iam_role_policy_attachment" "default" {
policy_arn = "${aws_iam_policy.build_policy.arn}"
role = "${aws_iam_role.build_role.id}"
}

resource "aws_codebuild_project" "build" {
name = "roryhow-blog-${var.env}"
service_role = "${aws_iam_role.build_role.arn}"
badge_enabled = true
build_timeout = "60"

artifacts {
type = "NO_ARTIFACTS"
}

environment {
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/ubuntu-base:14.04"
type = "LINUX_CONTAINER"
privileged_mode = false

environment_variable = [
{
"name" = "STAGE"
"value" = "${var.env}"
},
{
"name" = "GITHUB_TOKEN"
"value" = "${var.gh_token}"
},
{
"name" = "BUCKET_NAME"
"value" = "${var.bucket}"
}
]
}

source {
type = "GITHUB"
location = "${var.gh_repo}"
report_build_status = true
}
}

resource "aws_codebuild_webhook" "blog_hook" {
project_name = "${aws_codebuild_project.build.name}"
branch_filter = "${var.branch_filter}"
}

I'll summarise the functionality of this code snippet:

Phew - I'm glad that's over. Now, we just need to make this s3 bucket publicly available via a CDN, using CloudFront!

CloudFront #

We want to make the site as capable as we can to deal with large amounts of traffic, as well as making it accessible on a global scale, and minimising request times. Here, we use the aws_cloudfront_distribution module for the aws provider.

resource "aws_cloudfront_distribution" "blog_distribution" {
origin {
domain_name = "${aws_s3_bucket.blog_bucket.website_endpoint}"
origin_id = "${var.bucket}-${var.env}"
custom_origin_config {
http_port = "80"
https_port = "443"
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1", "TLSv1.1", "TLSv1.2"]
}
}

enabled = true
is_ipv6_enabled = true
comment = "Rory How - Blog"
default_root_object = "index.html"

aliases = "${var.site_aliases}"

default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "${var.bucket}-${var.env}"

forwarded_values {
query_string = false

cookies {
forward = "none"
}
}

viewer_protocol_policy = "allow-all"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
compress = true
viewer_protocol_policy = "redirect-to-https"
}

price_class = "PriceClass_All"

restrictions {
geo_restriction {
restriction_type = "none"
}
}

tags = {
Environment = "${var.env}"
}

viewer_certificate {
# sorry this will need to be static for now
acm_certificate_arn = "${var.site_cert_arn}"
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.1_2016"
}
}

There's a few notable parts of this snippet, so lets go over them:

Once this is done, you can run terraform apply, type yes when prompted and (hopefully) see lots of lines of green text showing you that your resources have been created. If this is the case, then congratulations, you made it! 🎉🎉🎉

Some Extra Legwork (...sorry) #

Once this is done, you won't (yet) be able to access your site using your defined aliases. This is because you need to create a CNAME record for your domain, pointing to the domain name of your created CloudFront resource. Once some time has passed and the DNS has resolved, then you should be able to access your site from your very own domain.

Future Options #

This was a ton of fun for me to work out, and I'm really happy with the result. This site is fast, deploys automatically from my git repository, and I can spin off a new environment by tweaking some environment variables.

So... how can I do this better?

Conclusion #

And there you have it! A completely overkill infrastructure for a very simple static site, managed (almost) entirely via Terraform, and it all stays within the free tier of AWS (as long as the CDN isn't too busy 😉) You can find the full source code of this site, here.

If you have any tips or suggestions (I'm still pretty new to Terraform and AWS as a whole), please feel free to get in contact with me on my twitter. Thanks for reading!

Published