Creating a site with Jekyll, Forestry.io, AWS and CloudFlare

Posted 03 Jan 2019

The following guide outlines the way I’ve set up this website. Having created a half dozen of these - each typically with a year long lifespan - I wanted the latest site to be something that was:

  • Fully customisable, to avoid the cookie cutter feel;
  • Easy to update;
  • Simple structure and therefore simple to manage; and
  • SEO friendly, including being well optimised.

Stack selection

To achieve these goals I’ve settled on the following tech stack:

  • Structure via Jekyll, allowing for simple, Markdown-based content.
  • Build scripts using Webpack.
  • Remotely managed by Forestry.io, providing a web-based editor and simple GitHub workflow.
  • Hosted on S3.
  • Served via CloudFront, providing fast requests.
  • DNS via CloudFlare.

Project setup with Jekyll

Create a new Jekyll project using jekyll new YOUR_SITE

Create a .forestry/settings.yml. My file looks like:

---
new_page_extension: md
auto_deploy: true
admin_path: ''
webhook_url: 
sections:
- type: jekyll-pages
  label: Pages
  create: all
- type: jekyll-posts
  label: Posts
  create: all
upload_dir: uploads
public_path: "/uploads"
front_matter_path: ''
use_front_matter_path: false
file_template: ":filename:"
build:
  preview_command: yarn build
  publish_command: yarn build
  preview_env:
  - JEKYLL_ENV=staging
  publish_env:
  - JEKYLL_ENV=production
  - NODE_ENV=production
  output_directory: _site

Building CSS/JS using Webpack incl. CSS inlining

I’m using Webpack to build the CSS and JS. The project uses the following webpack.config.js:

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const UglifyJsPlugin = require("uglifyjs-webpack-plugin");

const debug = process.env.NODE_ENV !== "production";

module.exports = {
  mode: process.env.NODE_ENV || "production",
  context: __dirname,
  devtool: debug ? "inline-sourcemap" : false,
  entry: "./_scripts/index.js",
  output: {
    path: __dirname,
    filename: "build/scripts.js"
  },
  optimization: {
    minimizer: [new UglifyJsPlugin()]
  },
  module: {
    rules: [
      {
        test: /\.(sa|sc|c)ss$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader", // translates CSS into CommonJS
          "sass-loader" // compiles Sass to CSS, using Node Sass by default
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "_includes/build/[name].css",
      chunkFilename: "[id].css"
    })
  ]
};

Of note, I’m using the MiniCssExtractPlugin so I can pull the CSS out of the bundle. This allows me to inline the CSS using the following liquid markup:

<style>{% include build/styles.css %}</style>

This step is useful if your CSS is small, or you’re only expecting a single page hit, as it avoids the need for an additional file request before your site can be properly viewed.

Setting up S3 and CloudFront

On AWS create an S3 bucket with public access rights:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadForGetBucketObjects",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::BUCKET_NAME/*"]
    }
  ]
}

Within the bucket settings, ensure it support static site hosting.

Create a CloudFront distribution. When selecting the origin, use this form of URL: BUCKET_NAME.s3-website-ap-southeast-2.amazonaws.com

Deploying from Forestry

To provide Forestry.io access to deploy to your bucket, create a user and attach a policy with the following permissions:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "VisualEditor0",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::BUCKET_NAME/*"
    },
    {
      "Sid": "VisualEditor1",
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::BUCKET_NAME"
    }
  ]
}

Remapping index.html

CloudFront, by default, uses full paths, e.g. example.com/foo/index.html. To support example.com/foo, we need to create a Lambda function that remaps the request. The following function (based on this this AWS guide) performs a simple remap:

'use strict';
exports.handler = (event, context, callback) => {
  var request = event.Records[0].cf.request;
  var olduri = request.uri;
  var newuri = olduri.replace(/\/([^./]+)\/?$/, '\/$1\/index.html');
  request.uri = newuri;
  return callback(null, request);
};

Set this to be trigger by a CloudFront event, namely an Viewer Request.

Please note that you need to create the function in North Virginia in order to use the CloudFront trigger.

Inavlidate CloudFront on change

To ensure CloudFront is cleared after each upload, we can use another Lambda function. The following is based on this example:

var aws = require('aws-sdk');
var cloudfront = new aws.CloudFront();

var distributionId = "CLOUD_FRONT_ID";

exports.handler = function (event, context) {
  console.log('Loading event');

  var params = {
    DistributionId : distributionId,
    InvalidationBatch : {
      CallerReference : '' + new Date().getTime(),
      Paths : {
        Quantity : 1,
        Items : [ '/*' ]
      }
    }
  };

  cloudfront.createInvalidation(params, function (err, data) {
    if (err) {
      context.done('error', err);
      return;
    }
    context.done(null, '');
  });
};

Please note that you need to create this in same region as your S3 bucket. Add an S3 trigger and select your Forestry bucket.

Rough edges

  • Invalidate only once.
  • Slow CSS building