← Back to work

your first devops project: jekyll on aws with cdk


Deploying a jekyll site to s3 & cloudflare

As a reformed content management system builder, I’ve finally come around to the realization that 99.9% of websites should just be static generated and hosted on s3. No platforms or saas, and even basic e-commerce can be accomplished without turning over the whole thing to a third party service. This post is written for those who care enough about computers to get their hands dirty.

Infrastructure should deploy automatically with SSL, proper permissions, etc., with zero hands-on with the AWS console. If you seek any future in the devops side of computers, this would be a simple little starter project. This is the most basic of devops / infrastructure as code.

Changes to the site should also happen automatically - when we merge a pull request to master, an action should handle everything for us and provide basic status.

If you’re new or just interested in devops, this post should help you learn some of the principles that scale to production-level systems. Doing something simple like deploying a static website can literally be as easy as dragging files into an FTP… but that’s pretty damn lame and doesn’t help you build skills.

Desired outcome

  1. Deploy infrastructure with one command (s3, all configs, permissions)
  2. Fully automate deployment using github actions (including cleanup)
  3. Automatic SSL installation, renewal, etc.
  4. https:// with redirect from www and http://

Items 1 and 2 set the standard that we should not have to intervene by logging into the AWS console or touching anything to manage a deployment. We should be able to edit code, create posts and pages, and push to git, that’s it. Logging in to reset cloudflare cache means failure. Having to open the S3 bucket and look around is failure.

Items 3 and 4 address user-facing items that are important and easy to underestimate, especially if using S3 as the host. It’s quite JV to have https://thewebsite.example and https://www.thewebsite.example, and it’s ultra JV to have http:// versions of those that have to be manually redirected or (worse) don’t work at all. No, we need proper redirects on all of this, and we’ll expect http://thewebsite.example to be the primary destination.

Manual (one-time, ever) steps:

  • Move domain DNS to Route 53
  • Create an S3 bucket where the site will be hosted
  • Create an IAM user with limited access that will only be used to deploy the CloudFront infrastructure stack
  • Create an IAM user with extra-limited permissions who will be able to publish to the jekyll site only, once it is deployed.
  • Initialize a CDK project, set up AWS credentials / cli locally

Use this policy as the starting point for your bucket. Assume everything published to the static site is intended to be public readable. Do not turn on web hosting as a bucket feature. Since we’ll be using CloudFront, we don’t need it. Also uncheck “block all public access” and acknowledge the danger zone.

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

We create the S3 bucket outside of the CDK scripts because if we choose to tear down the configuration and rebuild entirely, we don’t want to lose our bucket. I often do the same thing with database stacks and such; just set them up separately so you can decide how much to light on fire.

Now in IAM, create a new userw that will be used to deploy the cdk infrastructure. This user shoul dnot have console permissions as it will only use API (command line) access. Edit that user and attach two IAM policies that look like this. Edit of course.

Policy 1: CloudFormation + SSM

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "WhoAmI",
      "Effect": "Allow",
      "Action": "sts:GetCallerIdentity",
      "Resource": "*"
    },
    {
      "Sid": "ReadCdkBootstrapParam",
      "Effect": "Allow",
      "Action": ["ssm:GetParameter", "ssm:GetParameters"],
      "Resource": "arn:aws:ssm:us-east-1:AWSACCOUNTID:parameter/cdk-bootstrap/*"
    },
    {
      "Sid": "CFNCreateBits",
      "Effect": "Allow",
      "Action": [
        "cloudformation:CreateStack",
        "cloudformation:CreateChangeSet",
        "cloudformation:ValidateTemplate"
      ],
      "Resource": "*"
    },
    {
      "Sid": "CFNManageStack",
      "Effect": "Allow",
      "Action": [
        "cloudformation:UpdateStack",
        "cloudformation:DeleteStack",
        "cloudformation:DescribeStacks",
        "cloudformation:DescribeStackEvents",
        "cloudformation:DescribeStackResources",
        "cloudformation:GetTemplate",
        "cloudformation:ExecuteChangeSet",
        "cloudformation:DeleteChangeSet",
        "cloudformation:ListStackResources",
        "cloudformation:DescribeChangeSet",
        "cloudformation:ListChangeSets",
        "cloudformation:TagResource",
        "cloudformation:UntagResource"
      ],
      "Resource": [
        "arn:aws:cloudformation:us-east-1:AWSACCOUNTID:stack/*",
        "arn:aws:cloudformation:us-east-1:AWSACCOUNTID:changeSet/*"
      ]
    }
  ]
}

Policy 2: CloudFront, ACM, Route53

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ACMForCerts",
      "Effect": "Allow",
      "Action": [
        "acm:*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "CloudFrontDistroOAIAndFunctions",
      "Effect": "Allow",
      "Action": [
        "cloudfront:*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "Route53HostedZone",
      "Effect": "Allow",
      "Action": [
        "route53:ChangeResourceRecordSets",
        "route53:ListResourceRecordSets",
        "route53:GetHostedZone"
      ],
      "Resource": "arn:aws:route53:::hostedzone/*"
    },
    {
      "Sid": "AllowCreateCloudFrontServiceLinkedRole",
      "Effect": "Allow",
      "Action": "iam:CreateServiceLinkedRole",
      "Resource": "arn:aws:iam::AWSACCOUNTID:role/aws-service-role/cloudfront.amazonaws.com/AWSServiceRoleForCloudFront"
    }
  ]
}

With those policies attached, create security credentials for the user and drop them into a new file at ~/.aws/config

Create another IAM user with this inline policy. Use a policy name like “publish_jekyll_thewebsite_example” – we’ll need to refer to this in another step so we can automatically give this user access to invalidate the CloudFront cache.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowS3Sync",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::thewebsite.example",
        "arn:aws:s3:::thewebsite.example/*"
      ]
    }
  ]
}

Installing aws and cdk cli

However you like to install Node things is fine. I prefer using n to manage my node things. One important thing - when we install aws-cdk, note that we are using “aws-cdk-lib” which is AWS CDK v2. When you search for docs / examples, it’s very important to understand that you’re on this version because old versions have a lot of examples that sortof half work but break in ways that are very difficult to understand if you’re comparing two versions and don’t realize it.

% brew install n      # manages node versions, keeps things clean
% n install stable    # install a stable node
% n stable            # use the stable node for this session
% which npm 
# should show something like this:
/Users/chairs/.n/bin/npm
% npm i -g aws-cdk

To install the aws command… just do whatever this says.

Now, if you’re new to devops world or you just happen to be particularly lazy, you’re going to be tempted to just create a user and slam in the world of access. Don’t do that. Additionally, don’t git commit or even have your credentials anywhere near this project. We’re going to set up a limited user that only has access to the minimum needed to deploy our CDK infrastructure and manage publishing of our site.

TODO: Instructions for setting up limited aws credentials with only enoughk permissions to do that which we care about in our CDK scripts.

Infrastructure as Code with CDK

Thinking like a devops person means becoming comfortable with the idea that you should be able to tear down and rebuild critical infrastructure easily and reaeatibly. CDK basically creates CloudFormation stacks that work together to deploy projects in slices. In this project, we only have one slice of infrastructure and we can destroy it and rebuild idempotently. On larger projects, you may have a stack you call “foundation” that sets up VPCs, dns, and ssl. Then you might have a “database” stack that sets up rds clusters, and stacks for individual applications that live in that infrastructure. Getting good at devops means having the ability to tear down layers and rebuild them any time.

I like to structure my jekyll sites with the following folder structure. Create folders & touch files to get the same structure.

37chairs.com/
- .git/                   # git root for the project 
- jekyll/                 # Contains our jekyll site, plugins, etc. 
- jekyll/_site            # Will ultimately be where our site is rendered before deployment
- jekyll/_site/index.html # Manually created index.html in this specific folder for testing infra
- infrastructure/          # Contains our CDK project for deploying infrastructure
deploy_jekyll.sh          # Script that deploys jekyll manually
README.md                 # Include notes about deployment processes / commands, anything unique needed to get env happy, etc.

Now go in the infrastructure/ folder run:

% cdk init app --language=typescript
% cdk list
InfrastructureStack

# Probably a bunch of notices and junk

It’s convenient that the cdk init script starts with the InfrastructureStack, we’ll use that. Go ahead and put this into that file. Down the road you can use environment variables and such, but for now we’ll just get it running.

import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as cdk from "aws-cdk-lib";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as route53 from "aws-cdk-lib/aws-route53";
import { Construct } from "constructs";
import * as targets from "aws-cdk-lib/aws-route53-targets";

export class InfrastructureStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, {
      env: {
        account: process.env.CDK_DEFAULT_ACCOUNT,
        region: process.env.CDK_DEFAULT_REGION,
      },
      ...props,
    });

    // Use existing S3 bucket
    const bucket = s3.Bucket.fromBucketName(
      this,
      "WebsiteBucket",
      "thewebsite.example",
    );

    // Setting up the domain
    const domainName = "thewebsite.example";
    const hostedZone = route53.HostedZone.fromLookup(this, "Zone", {
      domainName,
    });

    const certificateName = "TheWebsiteCertificate";
    const certificate = new acm.Certificate(this, certificateName, {
      certificateName: certificateName,
      domainName: domainName,
      subjectAlternativeNames: ["www.thewebsite.example"],
      validation: acm.CertificateValidation.fromDns(hostedZone), // Use DNS validation
    });

    const oai = new cloudfront.OriginAccessIdentity(this, "OAI");
    bucket.grantRead(oai);

    const distribution = new cloudfront.Distribution(this, "SiteDistribution", {
      defaultBehavior: {
        origin: new origins.S3Origin(bucket, { originAccessIdentity: oai }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      defaultRootObject: "index.html",
      domainNames: [domainName],
      certificate,
    });

    const redirectFunction = new cloudfront.Function(this, "RedirectFunction", {
      code: cloudfront.FunctionCode.fromInline(`function handler(event) {
        var request = event.request;
        var headers = request.headers;
        return {
          statusCode: 301,
          statusDescription: 'Moved Permanently',
          headers: {
            'location': {
              value: 'https://thewebsite.example'
            }
          }
        };
      }`),
    });

    const redirectDistribution = new cloudfront.Distribution(
      this,
      "RedirectDistribution",
      {
        defaultBehavior: {
          origin: new origins.HttpOrigin("thewebsite.example"), // Dummy origin, since we are only redirecting
          functionAssociations: [{
            function: redirectFunction,
            eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
          }],
          viewerProtocolPolicy:
            cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
        domainNames: ["www.thewebsite.example"],
        certificate,
      },
    );

    new route53.ARecord(this, "RedirectAliasRecord", {
      recordName: "www.thewebsite.example",
      target: route53.RecordTarget.fromAlias(
        new targets.CloudFrontTarget(redirectDistribution),
      ),
      zone: hostedZone,
    });

    // Route53 alias record for the S3 bucket
    new route53.ARecord(this, "SiteAliasRecord", {
      recordName: domainName,
      target: route53.RecordTarget.fromAlias(
        new targets.CloudFrontTarget(distribution),
      ),
      zone: hostedZone,
    });
  }
}

const app = new cdk.App();
new InfrastructureStack(app, "InfrastructureStack");

Now let’s deploy the stack

% cdk deploy InfrastructureStack

It’ll go through a bit of set up, but should only take a minute or so. When we run the deploy command, the CDK framework sets up an AWS CloudFormation stack with all of the settings we defined. If you log in to the AWS Console and browse to CloudFormation, you’ll see the stack as it progresses through the deployment. If for some reason it fails, it’ll show an error and roll back changes. You can look at the errors and figure out what needs to be adjusted. If (when) this happens, it’s important to fix the CDK script and re-deploy. Don’t edit anything in the AWS console. In my case, my IP often changes and I don’t have VPN set up for every environment I use, so I have CDK scripts that do nothing more than touch a security group and update my ingress IP, for example. This both saves time because the script runs in less time than it’d take me to sign into the console, but it also maintains consistency in the stacks. If I need to edit, tear something down, redeploy, etc., I can be reasonable sure it will deploy cleanly again.