Nickolas Kraus

Hosting a Static Website with Hugo and CloudFormation

In a previous article, I detailed the steps for creating and hosting a static website on AWS. This process can be easily accomplished using CloudFormation, which provides a common language for describing and provisioning infrastructure resources in AWS.

Hugo

Hugo is a static site generator. The purpose of a static site generator is to render content into HTML files before the request for the content is made — increasing performance and reducing load time. To achieve this, Hugo uses a source directory of files and templates as input to create a complete website.

Getting Started

  1. Install Hugo:

    brew install hugo
    
  2. Create a new site:

    hugo new site my-site
    
  3. Add source control:

    cd my-site
    git init
    
  4. Choose a theme:

    Pre-made themes can be found here. This website uses a custom theme that I created, which can be found here.1

    git submodule add git@github.com:<username>/<theme>.git themes/<theme>
    

    Next, copy the config.toml from your chosen template into your own.

  5. Add content:

    hugo new posts/my-first-post.md
    

    NOTE: This will create a new directory, posts, and file, my-first-post.md in the content directory.

  6. Start the Hugo server:

    hugo server -D
    

    This will bootstrap your static site. For a more in-depth look at Hugo and how to use it, check out their documentation.

    NOTE: The -D option will include content marked as draft when running the server or generating static content.

  7. Generate static files:

    hugo
    

Amazon CloudFormation

Overview

Hosting a static website on AWS makes use of the following resources:

  • Amazon S3
  • AWS Certificate Manager
  • Amazon CloudFront
  • Amazon Route 53

Prerequisites

First, you must purchase a domain name through Amazon. I plan to automate this process in the future; however, for the time being this can be done through the AWS Management Console.

Creating the CloudFormation template

The CloudFormation template is as follows:

template.yaml

AWSTemplateFormatVersion: "2010-09-09"

Description: Static website for Hugo

Parameters:
  DomainName:
    Description: Domain name of website
    Type: String

Resources:
  S3BucketLogs:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    Properties:
      AccessControl: LogDeliveryWrite
      BucketName: !Sub "${AWS::StackName}-logs"

  S3BucketRoot:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    Properties:
      AccessControl: PublicRead
      BucketName: !Sub "${AWS::StackName}-root"
      LoggingConfiguration:
        DestinationBucketName: !Ref S3BucketLogs
        LogFilePrefix: "cdn/"
      WebsiteConfiguration:
        ErrorDocument: "404.html"
        IndexDocument: "index.html"

  S3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3BucketRoot
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action: "s3:GetObject"
            Principal: "*"
            Resource: !Sub "${S3BucketRoot.Arn}/*"

  CertificateManagerCertificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref DomainName
      ValidationMethod: DNS

  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Aliases:
          - !Ref DomainName
        CustomErrorResponses:
          - ErrorCachingMinTTL: 60
            ErrorCode: 404
            ResponseCode: 404
            ResponsePagePath: "/404.html"
        DefaultCacheBehavior:
          AllowedMethods:
            - GET
            - HEAD
          CachedMethods:
            - GET
            - HEAD
          Compress: true
          DefaultTTL: 86400
          ForwardedValues:
            Cookies:
              Forward: none
            QueryString: true
          MaxTTL: 31536000
          SmoothStreaming: false
          TargetOriginId: !Sub "S3-${AWS::StackName}-root"
          ViewerProtocolPolicy: "redirect-to-https"
        DefaultRootObject: "index.html"
        Enabled: true
        HttpVersion: http2
        IPV6Enabled: true
        Logging:
          Bucket: !GetAtt S3BucketLogs.DomainName
          IncludeCookies: false
          Prefix: "cdn/"
        Origins:
          - CustomOriginConfig:
              HTTPPort: 80
              HTTPSPort: 443
              OriginKeepaliveTimeout: 5
              OriginProtocolPolicy: "http-only"
              OriginReadTimeout: 30
              OriginSSLProtocols:
                - TLSv1
                - TLSv1.1
                - TLSv1.2
            DomainName: !Sub "${S3BucketRoot}.s3-website.${AWS::Region}.amazonaws.com"
            Id: !Sub "S3-${AWS::StackName}-root"
        PriceClass: PriceClass_All
        ViewerCertificate:
          AcmCertificateArn: !Ref CertificateManagerCertificate
          MinimumProtocolVersion: TLSv1.1_2016
          SslSupportMethod: sni-only

  Route53RecordSetGroup:
    Type: AWS::Route53::RecordSetGroup
    Properties:
      HostedZoneName: !Sub "${DomainName}."
      RecordSets:
        - Name: !Ref DomainName
          Type: A
          AliasTarget:
            DNSName: !GetAtt CloudFrontDistribution.DomainName
            EvaluateTargetHealth: false
            HostedZoneId: Z2FDTNDATAQYW2
        - Name: !Sub "www.${DomainName}"
          Type: A
          AliasTarget:
            DNSName: !GetAtt CloudFrontDistribution.DomainName
            EvaluateTargetHealth: false
            HostedZoneId: Z2FDTNDATAQYW2

To make this CloudFormation template more extensible, I pass in the domain name as a parameter via a parameters.json file.

parameters.json

[
  {
    "ParameterKey": "DomainName",
    "ParameterValue": "static-website.com"
  }
]

Accessing the default root object from a subfolder or subdirectory

Unfortunately, Amazon CloudFront does not return the default root object (e.g., index.html) from a subfolder or subdirectory:

The default root object feature for CloudFront supports only the root of the origin that your distribution points to. CloudFront doesn’t return default root objects in subdirectories.

Amazon recommends that you integrate Lambda@Edge with your distribution; however, this is cumbersome and unnecessary. Simply create an Origin using the region-specific website endpoint of the S3 bucket:

bucket-name.s3-website-region.amazonaws.com

or

bucket-name.s3-website.region.amazonaws.com

It should be noted that Amazon S3 does not support HTTPS connections when configured as a website endpoint. You must specify HTTP Only as the Origin Protocol Policy for your CloudFront distribution:

...
Origins:
  - CustomOriginConfig:
      HTTPPort: 80
      HTTPSPort: 443
      OriginKeepaliveTimeout: 5
      OriginProtocolPolicy: "http-only"
      OriginReadTimeout: 30
      OriginSSLProtocols:
        - TLSv1
        - TLSv1.1
        - TLSv1.2
    DomainName: !Sub "${S3BucketRoot}.s3-website.${AWS::Region}.amazonaws.com"
    Id: !Sub "S3-${AWS::StackName}-root"

Validating and deploying the CloudFormation stack

$ aws cloudformation validate-template \
--template-body file://template.yaml
$ aws cloudformation create-stack \
--stack-name <stack-name> \
--template-body file://template.yaml \
--parameters file://parameters.json

NOTE: create-stack is used in order to pass parameters as a file. The deploy command can be used with the addition of cat:

$ aws cloudformation deploy \
--stack-name <stack-name> \
--template-file template.yaml \
--parameter-overrides $(cat parameters.properties)

parameters.properties

DomainName=static-website.com

Validating a certificate with DNS

When you use the AWS::CertificateManager::Certificate resource in an AWS CloudFormation stack, the stack will remain in the CREATE_IN_PROGRESS state and any further stack operations will be delayed until you validate the certificate request. Certificate validation can be completed either by acting upon the instructions in the certificate validation email or by adding a CNAME record to your DNS configuration.

The Status Reason for your CloudFormation deploy will contain the following:

Content of DNS Record is: {Name: _x1.static-website.com.,Type: CNAME,Value: _x2.acm-validations.aws.}

Where x1 and x2 are random hexadecimal strings.

To automate DNS validation, you can use this script.

./dns-validation.sh $DOMAIN_NAME $STACK_NAME

Automation limitations with DNS validation

Since CloudFormation only outputs the Name and Value for the validation of the root domain name, any other subdomain that you wish to validate (e.g., www) must be manually validated using the Name and Value given in the AWS Management Console.

If you want your website to be accessible via HTTPS on both the www subdomain and root domain, you will need to add an alternate name to the certificate and determine the Name and Value to validate the www subdomain manually:

CertificateManagerCertificate:
  Type: AWS::CertificateManager::Certificate
  Properties:
    DomainName: !Ref DomainName
    SubjectAlternativeNames:
      - !Sub www.${DomainName}
    ValidationMethod: DNS

You will then be able to add the www subdomain to the CloudFront distribution:

CloudFrontDistribution:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
      Aliases:
        - !Ref DomainName
        - !Sub "www.${DomainName}"

Testing the static website

First, your static website needs to serve some content.

hello.md

---
title: "Hello, World!"
date: 2019-08-18T00:00:00-00:00
draft: false
---

## Hello, World!

Upload public/ to the newly created S3 bucket:

aws s3 sync --acl "public-read" public/ s3://$S3_BUCKET_ROOT

Navigate to your static website!

Conclusion

The code for this CloudFormation stack, as well as other CloudFormation templates can be found at nickolashkraus/cloudformation-templates.


  1. I am currently using a different theme: Paperwhite↩︎

Last updated: