There are a couple of technologies I have recently become interested in, albeit for different reasons - Terraform and Hugo. Rarely does an opportunity present itself to me in which I can find a use case to try a couple of seemingly disparate technologies together. I decided to move my blog from WordPress on EC2 to a static website.

Here’s how I did it, and why.

Why Static Website?

Up until recently, I was hosting this blog on an EC2 instance running WordPress. I did not want to use WordPress.com or a similar managed hosting service, because I wanted to have finer-grained control over it. Even with a tiny EC2 instance, it seemed like overkill to have a full VM running WordPress to serve up a handful of static pages, and more expensive than needed.

I was turned on to the idea of static websites from reading through the Terraform documentation, which is built with Middleman. I found this to be a powerful way to manage product documentation, and started to research more about static website generators.

Terraform

Terraform is a configuration management technology for managing your infrastructure with code, with a stateful representation paradigm similar to how server configuration is commonly managed with technologies such as Chef, Puppet, Ansible, and others.

Hugo

Hugo is a super-fast static website generator written in Go, similar to Jekyll, Middleman, and others.


Goals

I wanted this exercise to be more than just a proof of concept for Terraform and Hugo. I had several things I wanted to improve over my EC2 hosted solution.

Cheaper Hosting

As far as cloud services go, file storage is among the cheapest. Most providers, including AWS, provide a simple website service on top of the file storage service, providing a cheap solution for hosting static websites. I chose Amazon S3.

High(er) Availabilty

Instances in EC2 (and in the public cloud in general, to be fair) are pretty volatile. They can reboot (or disappear) at random times. And if hosting a simple blog on WordPress on a single EC2 instance wasn’t overkill enough, adding multiple instances with load balancing across availability zones for high availability would be - more overkill?

Amazon S3 provides 99.99% availability, which is better than what you can expect from a single EC2 instance.

Simple Content Management

Now that I had somewhere to stash and serve up my soon-to-be static content, I needed a framework for producing, managing, and publishing content. One of the gripes I have with content management systems such as WordPress, is that the content is separated from the source. I didn’t want to be creating MySQL backups or XML exports and storing them somewhere else. I am more comfortable having my content be my source, so that I can version it using common revision control systems, like Git.

Simple Configuration Management

Even though an S3 bucket is simpler to manage than an EC2 instance, I wanted to have a way to manage it as well as read/write permissions to it. Terraform is a powerful technology that can manage complex infrastructures, but is also suited for simple configurations like mine.


Implementation

Directory Layout

The basic structure of the blog is shown below. I removed some of the detail to remove clutter:

├── Makefile
├── archetypes
│   ├── page.md
│   └── post.md
├── bin
│   └── admin
├── config.toml
├── content
│   ├── page
│   └── post
├── data
├── layouts
│   ├── partials
│   └── shortcodes
├── static
│   ├── css
│   └── images
├── terraform
│   ├── main.tf
│   ├── terraform.tfstate
│   ├── terraform.tfstate.backup
│   └── terraform.tfvars
└── themes

The Hugo source files are in the root, while the Terraform configuration resides in terraform/ directory.

Terraform Configuration

My Terraform configuration is a simple configuration that manages the following resources:

  • S3 bucket - The website-enabled bucket that will host the static pages
  • Publish User - The IAM user with read/write permissions to the bucket

First, I define the Terraform variables I want to use in my configuration, so that they can be separated from the configuration.

variable "region"        { default = "us-east-1" }
variable "access_key_id" { }
variable "secret_key"    { }
variable "bucket"        { }

Then, I configure the Terraform AWS provider with AWS credentials that have sufficient privileges to perform the operations:

provider "aws" {
  region     = "${var.region}"
  access_key = "${var.access_key_id}"
  secret_key = "${var.secret_key}"
}

Next, I create the S3 bucket, configured with static web hosting:

resource "aws_s3_bucket" "site-bucket"  {
  bucket = "${var.bucket}"
  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::${var.bucket}/*"
    }
  ]
}
EOF

  website {
    index_document = "index.html"
    error_document = "404.html"
  }
}

Next, I create an IAM user that will have read/write permissions to the bucket:

resource "aws_iam_user" "publisher" {
  name = "${var.bucket}-site-publisher"
}

resource "aws_iam_access_key" "access-key" {
  user = "${aws_iam_user.publisher.name}"
}

resource "aws_iam_user_policy" "publisher-policy" {
    name = "FullAccess-${var.bucket}"
    user = "${aws_iam_user.publisher.name}"
    policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1471182003000",
            "Effect": "Allow",
            "Action": [
                "s3:*"
            ],
            "Resource": [
                "arn:aws:s3:::${var.bucket}",
                "arn:aws:s3:::${var.bucket}/*"
            ]
        }
    ]
}
EOF
}

Finally, I declare a few outputs to be used when publishing content via Hugo:

output "publish-user" {
  value = "${aws_iam_user.publisher.name}"
}

output "publish-user-access-key" {
  value = "${aws_iam_access_key.access-key.id}"
}

output "publish-user-secret-key" {
  value = "${aws_iam_access_key.access-key.secret}"
}

And thats it! Below is the complete Terraform configuration:

terraform/main.tf

variable "region"        { default = "us-east-1" }
variable "access_key_id" { }
variable "secret_key"    { }
variable "bucket"        { }

provider "aws" {
  region     = "${var.region}"
  access_key = "${var.access_key_id}"
  secret_key = "${var.secret_key}"
}

resource "aws_s3_bucket" "site-bucket"  {
  bucket = "${var.bucket}"
  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::${var.bucket}/*"
    }
  ]
}
EOF

  website {
    index_document = "index.html"
    error_document = "404.html"
  }
}

resource "aws_iam_user" "publisher" {
  name = "${var.bucket}-site-publisher"
}

resource "aws_iam_access_key" "access-key" {
  user = "${aws_iam_user.publisher.name}"
}

resource "aws_iam_user_policy" "publisher-policy" {
    name = "FullAccess-${var.bucket}"
    user = "${aws_iam_user.publisher.name}"
    policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1471182003000",
            "Effect": "Allow",
            "Action": [
                "s3:*"
            ],
            "Resource": [
                "arn:aws:s3:::${var.bucket}",
                "arn:aws:s3:::${var.bucket}/*"
            ]
        }
    ]
}
EOF
}

output "publish-user" {
  value = "${aws_iam_user.publisher.name}"
}

output "publish-user-access-key" {
  value = "${aws_iam_access_key.access-key.id}"
}

output "publish-user-secret-key" {
  value = "${aws_iam_access_key.access-key.secret}"
}

Now that the configuration is setup, I need to supply values for the variables defined in the beginning. This is provided via the terraform/terraform.tfvars file:

region = "us-east-1"
bucket = "my-bucket"
access_key_id = "ACCESS_KEY_ID"
secret_key = "SECRET_ACCESS_KEY"

To view the changes that Terraform will apply, from within the terraform/ directory:

terraform plan

NOTE: The first time running this will show everything in the new state. Subsequent runs of terraform plan will show different output based on the state of the infrastructure.

To apply the changes, from within the terraform/ directory:

terraform apply

NOTE: If the above succeeded, the state will be saved in terraform/terraform.tfstate. Terraform will use this file when planning/applying subsequent changes.

IMPORTANT: The terraform/terraform.tfstate file contains the ACCESS_KEY_ID and SECRET_ACCESS_KEY values for the user created by Terraform. If you are hosting your source code in a public repository, you will want to store this file outside of your repository. See Additional Resources section below for how to store Terraform state files remotely.


Hugo Setup

I won’t get in to how to actually build sites with Hugo, as there are plenty of great detailed articles on the internet on how to do that, including the Hugo Docs.

Now that I can manage the hosting infrastructure, I need to publish the Hugo generated content to the S3 bucket. Building the static content via Hugo is extremely simple:

hugo -b http://my-domain-or-ip/

This will build the static content in the public/ directory. Now I need to push this content to the S3 bucket that will host our site. I will use the AWS CLI tools to synchronize the public/ directory with the bucket, as follows:

aws s3 sync public/ s3://my-bucket/

The AWS CLI requires AWS access keys to be configured (See Additional Resources). Because I am using access keys that belong to a user created by my Terraform configuration, I will use Terraform’s output command to retrieve the values from the state file at publish time. I will also use a Makefile to connect a few things for me:

Makefile

export AWS_ACCESS_KEY_ID := $(shell terraform output -state terraform/terraform.tfstate publish-user-access-key)
export AWS_SECRET_ACCESS_KEY := $(shell terraform output -state terraform/terraform.tfstate publish-user-secret-key)

.PHONY: build push

build:
    hugo -b http://dotariel.com/
dev:
    hugo server -t ghostwriter -b http://localhost:1313/ --buildDrafts
push: build
    aws s3 sync public/ s3://dotariel.com/

Now, I can update my site and publish when ready:

make push

My next improvement will be to automate the deployment on commit ;)

Fun, right?


Additional Resources