Building a static website generator with React, AWS Lambda and Terraform

A guide to creating an AWS cloud service with Terraform

My weekend goal was to finally publish something to my domain. I’ve been playing around with Terraform and have been looking for an excuse to build an actual project so I decided to pull the trigger and finally commit to it.

While thinking about what to make exactly, I came to the realization that I don’t have a lot of content for a typical portfolio but I share work on other services like Github and Twitter. I know I also don’t want to spend a bunch of time thinking about what to put on it.

What I ended up building was a self-updating static website. The web service summarizes my latest activity from Github, Twitter and Medium, generates a static website, and publishes the content to my domain. Besides serving as an over-engineered, non-transferable business card, it’s a convenient launcher for the apps I use most when added to the iOS home screen.

Here’s the final product:

jschr.io — Add to iOS home screen

I built it with React, Webpack and Terraform. If you want to skip right to the code, its available on Github along with instructions on how to deploy your own version.

Read on if you’re interested in an in-depth overview of the selected stack and project structure. The examples are written in Typescript and assumes some familiarity with React and Webpack.

What’s Terraform?

Terraform is a tool for creating infrastructure as code with declarative configuration files. One major benefit of writing your infrastructure as code is for free, you get all the power of version control, code reviews and collaboration.

With Terraform’s declarative configuration files you can use variables and modules to make it a really easy to create and manage your infrastructure. Terraform is similar to AWS’ CloudFormation, except that it has a growing list of providers you can use to create resources in many other cloud services.

Heres what a Terraform config looks like:

Terraform config to create an AWS S3 bucket.

Checkout out the intro docs for more use cases and comparisons. Terraform is built by the team over at Hashicorp.

Project structure

Let’s start with an overview of the app’s structure:

High-level view of the project structure.

Creating the Webpack config

The first step and where most of the magic happens is creating the webpack config. This project wouldn’t have been nearly as easy without this awesome static website generator plugin for webpack.

Here’s a simple webpack config using the plugin:

Basic webpack config for generating a static website.

Tip #1: Webpack supports Typescript for config files if it has a .ts extension and ts-node is installed locally for your project.

Every property passed in through the locals option of the static site generator plugin will be sent to the server-side render function.

Example server-side render function.

Using the locals option is how we will pass in the app’s props whenever we generate a new static website.

To make our site more dynamic, the next step is hooking it up to some real data. You might pull data from a local markdown file or fetch it from an API. Before being able to fetch anything, we’ll need to make a couple changes to the webpack config.

In order to make use of async/await we are going convert our config to export an async function, which works out-of-the box with webpack. We can then fetch our latest activity from Github and render the app with the getProps function.

An async webpack config to fetch data for generating our static website, view the full source.

Our getProps function to fetch the latest activity from Github, view full the source.

Tip #2: Webpack supports exporting a function from the config. It will receive any env options you set via the command line.

The full source of the webpack config can be found in the repo. The same config is used to start the dev server and to generate a new static website in our Lambda function.

Server-side rendering

The server-side render function receives the app props we fetched in the webpack config as well as a stats object we can use to inject the javascript bundle into the html.

The server-side rendering function, view the full source.

The template receives the initial app render as an html string, any data that the client needs from the server (accessible via the window object) and any javascript bundles that need to be injected.

The template component, view the full source.

Mounting to the DOM

After rendering the initial page load, the browser needs to bootstrap the React app with the same props that were used to render the html. This happens in the mount function and we need to make sure that it’s only called when running in the browser.

Bootstrapping React in the browser, view the full source.

Note that index.ts runs in both node and browser contexts so you need to be extra cautious when importing libraries that depend on the browser APIs.

Now that we’ve setup server-side rendering and the browser client, let’s add some styling.

Adding styles with glamor

I chose to use glamor for styling, one of many great options for css in React.

Why glamor?

Adding glamor to the project involves two-steps:

Generate the css stylesheet in the server side render function and pass it to the template component for the initial page load css

Rehydrate glamor’s server state browser-side

Glamor has it’s own render function for server-side rendering that returns the initial html of the app and any styles that were created during the render. There are a couple gotchas related to the order of imports you need to be aware of when server-side rendering with glamor.

Adds styling via glamor to the ssr function and template component.

Now we just need to rehydrate the server state in the browser:

Rehydrate glamor state in the browser.

Here’s where you’d start adding a bunch of components but I’m going to skip over creating and styling components and move on to the actual Lambda function. You can see the components I made for my website in the repo if you’re curious.

Lambda function

For our backend we are going to setup an AWS Lambda function to run the webpack compiler and upload the result to an S3 bucket. All the Lambda needs is a javascript function and AWS will provision and manage the resources for you.

Here’s the entire Lambda function:

The Lambda handler, view the full source.

Tip #3: You can use memory-fs to to output the webpack build to memory instead of the file system

After the compilation step the Lambda will upload the files to S3 and invalidate the CloudFront distribution. Alternatively to setting up CloudFront, you can configure the bucket to be a static website.

Infrastructure

Now we’re ready to start creating the infrastructure. I’m going to use AWS Lambda, S3 and CloudFront for hosting the website and Mailgun for sending and receiving email with the domain.

Here’s an overview of the Terraform structure:

Terraform infrastructure overview.

Terraform is executed from the directory of the environment you want to deploy. In this case we only have one environment and running terraform apply from env-dev will deploy the dev infrastructure.

After each deploy, Terraform sill save the state of the the created resources into a terraform.tfstate file inside the current directory. When working in larger teams, you way want to use remote state.

Setting up the environment

In order to deploy the environment, we need to create it’s terraform variable file. Here’s a condensed version of ours:

Terraform variables for dev, view the full source.

When you run terraform apply, it will run every configuration file in the current directory. Since we are running the command from env-dev there is only one — dev.tf.

The dev configuration file authenticates with AWS and Mailgun then creates the app’s infrastructure.

Terraform environment config, view the full source.

The app module

Terraform modules let us create re-usable components of our infrastructure. Terraform can use modules from the filesystem, Github and other remote sources. This project uses one local module for the entire app but you can compose your infrastructure from as many modules as you’d like.

To create the app module we’ll start by defining it’s input variables:

Terraform config for the app module’s variables, view the full source.

This setup allows us to easily create more environments in the future like env-staging and env-production.

Now we can start creating all the resources for our app.

Adding the Lambda function

Building the project will package up our app to be uploaded to Lambda. When we deploy, Terraform will determine whether or not to deploy a new version of the handler using the package’s hash.

Lambda terraform config, view the full source.

Adding the S3 bucket

Create a private S3 bucket for our domain.

S3 bucket terraform config, view the full source.

Scheduling the Lambda with CloudWatch

We’ll use CloudWatch Events to trigger our Lambda every 15 minutes.

CloudWatch terraform config, view the source.

Adding the CloudFront CDN

Create a CloudFront distribution for our S3 bucket.

CloudFront terraform config, view the full source.

Adding the DNS records

Assuming you’ve registered your domain in Route 53, we’ll use data sources to fetch the hosted zone for our domain by name and create the DNS entries for our website.

Route 53 terraform config, view the full source.

Adding Mailgun

The fastest way to start sending and receiving email through our domain is to create a Mailgun account. Retrieve your api key from your account profile and add it to dev.tfvars.

Thanks to Terraform’s Mailgun provider we can create a new domain resource with Mailgun’s API right from our config file:

Mailgun provider terraform config, view the full source.

The resulting terraform output of creating the Mailgun domain will look something like this:

Mailgun domain output from running terraform show.

Ideally, we could tell Terraform to create a DNS entry for each receiving record and sending record but Terraform doesn’t currently support using count with computed values.

Mailgun route 53 count issue.

Since the number of records is known, we can manually create each record as a workaround:

Mailgun route 53 terraform config, view the full source.

All of our infrastructure is now set up using only terraform config files and you can now run terraform plan to preview the changes then terraform apply to deploy them.

You will need to trigger domain verification in Mailgun after the first deploy if you don’t want to wait up to 24 hours before you can start receiving emails.

The final step is adding a Mailgun route to forward emails to our main account. Here’s what mine looks like:

Mailgun catch-all route for email forwarding.

Final thoughts

Making changes to infrastructure and spinning up new environments has never been easier with Terraform. I don’t think I’ll be logging into the AWS console anytime soon.

You’re more than welcome to create, modify and deploy your own versions of the source code. I’d love to hear any suggestions you have for improvements or cool features I could add for a followup post.