AWS Static Site: CloudFormation

/ aws, cloudformation, hugo, blogging

Table of Contents - Infrastructure as Code #

  1. Intro
  2. AWS Static Site: Cloudformation
  3. AWS Static Site: Terraform
  4. AWS Static Site: CDK

CloudFormation #

When I began my blog this year and started using Hugo to generate the site, I thought first of hosting it on AWS. A simple static site has very few needs: a place to store and serve up the generated files, and a place to create a DNS record for visitors to use to get to the site.

AWS S3 has a static website hosting feature and AWS Route53 is a nice cheap place to register a domain and manage DNS records.

Let's see what it looks like to build out the infrastructure for a static site using using CloudFormation. Along the way we'll cover how to handle uploading a CloudFormation template to create a CloudFormation stack, and some of the permissions involved.

Defining the configuration in code (infrastructure-as-code or IaC) has a whole host of benefits, not least of which is documenting the exact configuration in a declarative manner.

Here's where we are going to end up:

Prerequisites #

Getting Started #

CloudFormation can be written using either JSON or YAML; since no sane person is going to write JSON by hand, we'll use YAML. The skeleton for a CloudFormation template looks like this:

AWSTemplateFormatVersion: "2010-09-09"
Description: Infrastructure for a static site.
Parameters:
  # Optional section for variables the user can set to 
  # configure the resources defined in the template.
Resources:
  # Required section for the resources the template will
  # create.
Outputs:
  # Optional section for values to output. Outputs can 
  # seen in the CloudFormation console, retrieved via 
  # the API, and accessed in other CloudFormation stacks.
Metadata:
  # Optional section for template metadata.
Rules:
  # Optional section for validating parameters or 
  # combinations of parameters.
Mappings:
  # Optional section for dictionaries of that can be 
  # used as a lookup table by resources. Mappings are 
  # primarily used to adapt templates for deployment into
  # different AWS Regions.
Conditions:
  # Optional section for conditions that can be applied 
  # to resources. Conditions that evaluate to `False` will 
  # prevent the resource from being created.
Transform:
  # Optional section for macros that CloudFormation 
  # should use to transform the template. Macros can be 
  # user-defined (backed by an AWS Lambda function) or 
  # a macro provided and hosted by CloudFormation.

At the beginning we will only be using Parameters, Resources, and Outputs but later on we'll look into adding Conditions and Rules.

The majority of the information needed to host a static website on S3 comes from these two pages in the AWS docs:

After enabling static website hosting on an S3 bucket AWS creates a website endpoint that differs from the normal REST endpoint used to interact with the bucket:

http://<bucket-name>.s3-website-<Region>.amazonaws.com1

vs.

https://<bucket-name>.s3.<Region>.amazonaws.com

Website endpoints have a few limitations:

  1. they do not support HTTPS
  2. in order to use a CNAME to give our bucket a friendlier DNS name, the bucket name and the CNAME must match2
  3. the bucket content must be publicly accessible

At the end of this post we'll look at adding HTTPS support via CloudFront which will allow using an arbitrary bucket name and making the bucket content private.

If you are puzzled (as I was) about why the S3 bucket needs to be configured as a static website, when it could just simply be a normal bucket serving up files, it's worth noting the differences between a normal S3 bucket and one configured for S3 hosting. The main differences are that buckets configured for static website hosting will redirect requests for the root and for subfolders to a (configurable) index document and will reqirect requests that do not match any file to a (configurable) error document.

Defining Resources #

Let's assume we want to be able to set up multiple blogs or other static sites using this this CloudFormation template (yay reuse!) so we'll allow specifying the subdomain and the DNS domain as parameters:

Parameters:
  DomainName:
    Type: String
    Description: Enter the domain name for the static site.
  SubdomainName:
    Type: String
    Description: Enter the subdomain name for the static site.

The domain name should match the name of a Route53 hosted zone in your AWS account.

Next, we'll need to create an S3 bucket. The CloudFormation docs show the properties to set in the CloudFormation template. We will also need some intrinsic functions to manipulate data and to reference other resources and properties.

Resources:
  S3Bucket:
    Type: "AWS::S3::Bucket"
    Properties:
      AccessControl: Private
      # To use a CNAME to access content in an S3 bucket, the
      # S3 bucket name must match the CNAME
      BucketName: !Join
        - "."
        - - !Ref SubdomainName
          - !Ref DomainName
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: 404.html
    DeletionPolicy: Retain

We use the intrinsic functions Join and Ref to take the supplied parameters and create a bucket with a name matching the DNS name we want to use for the static site. DeletionPolicy is one of a few properties that can be applied to any resource to provide additional control over creates/deletes/updates and resource dependencies.

Note that AccessControl is set to Private. There is a predefined Public ACL for S3 buckets, but it is not sufficient to allow anonymous public access to content in the bucket.3

We'll need a bucket policy to truly enable public access:

Resources:
  S3BucketPolicy:
    Type: "AWS::S3::BucketPolicy"
    Properties: 
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action:
              - "s3:GetObject"
            Effect: Allow
            Resource: !Sub
              - "arn:aws:s3:::${BucketName}/*"
              - BucketName: !Join
                - "."
                - - !Ref SubdomainName
                  - !Ref DomainName
            Principal: "*"

We use the intrinsic function Sub to construct an ARN referencing all content in the bucket and allowing GetObject to everyone (Principal: "*"). Using Ref we can refer to the parameters again and to the S3 bucket we defined above.

Note that the account wide "Block Public Access" setting cannot be enabled, or it will prevent this policy from taking effect.4

Finally, we need a DNS record to give us a more friendly name to use for accessing the static site:

Resources:
  DNSRecord:
    Type: "AWS::Route53::RecordSet"
    Properties:
      Comment: CNAME record for static site hosted on S3.
      # CloudFormation requires this property to include a 
      # trailing '.'
      HostedZoneName: !Sub
        - "${DomainName}."
        - DomainName: !Ref DomainName
      Name: !Join
        - "."
        - - !Ref SubdomainName
          - !Ref DomainName
      # Remove "http://" from the beginning of the URL
      ResourceRecords:
        - !Select
          - "1"
          - !Split
            - "//"
            - !GetAtt S3Bucket.WebsiteURL
      TTL: 900
      Type: CNAME

Here we use another intrinsic function GetAtt to retrieve a property from a resource we are creating that isn't known until the resource is created. Since the website URL is returned with an http:// prefix, we can split on // and use the Select intrinsic function to get the second part containing just the domain.

Let's also output the website URL so we can look at the CloudFormation outputs tab for it or retrieve it from the CloudFormation stack via the API.

Outputs:
  WebsiteURL:
    Value: !GetAtt S3Bucket.WebsiteURL
    Description: URL for website hosted on S3

Complete CloudFormation Template #

Combining all these elements gives us a template like this (also on GitHub):

AWSTemplateFormatVersion: "2010-09-09"
Description: Infrastructure for a static site.
Parameters:
  DomainName:
    Type: String
    Description: Enter the domain name for the static site.
  SubdomainName:
    Type: String
    Description: Enter the subdomain name for the static site.
Resources:
  S3Bucket:
    Type: "AWS::S3::Bucket"
    Properties: 
      AccessControl: Private
      # To use a CNAME to access content in an S3 bucket, the
      # S3 bucket name must match the CNAME
      BucketName: !Join
        - "."
        - - !Ref SubdomainName
          - !Ref DomainName
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: 404.html
    DeletionPolicy: Retain
  S3BucketPolicy:
    Type: "AWS::S3::BucketPolicy"
    Properties: 
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action:
              - "s3:GetObject"
            Effect: Allow
            Resource: !Sub
              - "arn:aws:s3:::${BucketName}/*"
              - BucketName: !Join
                - "."
                - - !Ref SubdomainName
                  - !Ref DomainName
            Principal: "*"
  DNSRecord:
    Type: "AWS::Route53::RecordSet"
    Properties:
      Comment: CNAME record for static site hosted on S3.
      # CloudFormation requires this property to include a 
      # trailing '.'
      HostedZoneName: !Sub
        - "${DomainName}."
        - DomainName: !Ref DomainName
      Name: !Join
        - "."
        - - !Ref SubdomainName
          - !Ref DomainName
      # Remove "http://" from the beginning of the URL
      ResourceRecords:
        - !Select
          - "1"
          - !Split
            - "//"
            - !GetAtt S3Bucket.WebsiteURL
      TTL: 900
      Type: CNAME
Outputs:
  WebsiteURL:
    Value: !GetAtt S3Bucket.WebsiteURL
    Description: URL for website hosted on S3

Deploy the CloudFormation Stack #

It's possible to use the AWS CloudFormation console to upload this template but let's continue down the path of getting the deployment defined in code in addition to the resources. We can use the AWS CLI to validate and deploy the CloudFormation template, taking arguments to the script and passing them to CloudFormation as as values for the parameters we defined.

The validate-template command does not do much beyond validating that the template is valid YAML. The deploy command is a lot more interesting. CloudFormation provides a few different ways to create and update stacks:

#!/usr/bin/env bash

set -eu

CLOUDFORMATION_FILE=static-site.yml

echo "==> Validating ${CLOUDFORMATION_FILE} ..."
aws cloudformation validate-template \
  --template-body file://$(pwd)/${CLOUDFORMATION_FILE}

echo "==> Deploying ${CLOUDFORMATION_FILE} ..."
aws cloudformation deploy \
  --stack-name "static-site-cf" \
  --template-file ${CLOUDFORMATION_FILE} \
  --parameter-overrides "SubdomainName=$1" "DomainName=$2"

Assuming you have stored your CloudFormation tempate in static-site.yml we can run ./deploy.sh blog charleshepner.com to deploy our static site hosting infrastucture!

A note about permissions.

CreateChangeSet ExecuteChangeSet ValidateTemplate

Publish Site #

At this point I have everything I need to publish my blog. Placing the following in my Hugo config.yaml.

deployment:
  targets:
    - name: S3
      URL: "s3://blog.charleshepner.com?region=us-east-1"

I can then run hugo && hugo deploy (assuming my AWS credentials are set either as environment variable or in my ~/.aws/credentials file).

Appendix #

CloudFormation provides a number of ways to allow templates to be dynamic and adapt to deployment into different contexts. For example, parameters allow the same template to deploy EC2 instances of different sizes; mappings allow the a template deployed in different regions to use the right AMI ID for that region, conditions allow the template to create some resources in certain AWS accounts and other resources in others, and transforms allow any type of programmatic manipulation of the template.

https://stackoverflow.com/questions/49082709/redirect-to-index-html-for-s3-subfolder