Static Sites

Static Sites

Static Sites and their deployment. Demystified.

The Basics

It all starts with a AWS CloudFormation Template and an Amazon S3 Bucket.

The beginnings of the site's template: We will dive into the details of each property as we fill them in.

Description: Static Site
 
Resources:
 
Outputs:

The AWSTemplateFormatVersion is optional and is omitted here.

Resources

The resources section is the more dense portion in all the land of yaml.

Bucket

This hosts our static site. The site consists of an index (index.html) document, an error (404.html) document, and directories for styles and images.

AWS CloudFormation Stack deletion fails for buckets that have contents.

SiteBucket:
  Type: AWS::S3::Bucket
  DeletionPolicy: Delete
  Properties:
    AcessControl: PublicRead
    BucketName: !Sub '${AWS::StackName}-site'
    WebsiteConfiguration:
      IndexDocument: index.html
      ErrorDocument: 404.html

The key take away from the above yaml snippet is the AccessControl property. S3 bucket misconfiguration is costly.

The website configuration definition informs the bucket to host the index page of the static site.

AWS CloudFormation will provide a name for the S3 bucket if one is not provided. We will be using the AWS Command Line Interface (CLI) to sync the contents of the static site. As such we define an easy to type name by concatenating the AWS CloudFormation stack name and the purpose of our bucket.

Bucket names must be globally unique. The AWS CloudFormation Stack will fail to create the S3 Bucket if the name has been taken.

It is important to highlight the Amazon S3 Deprecation Plan.

Bucket Names with Dots – It is important to note that bucket names with “.” characters are perfectly valid for website hosting and other use cases. However, there are some known issues with TLS and with SSL certificates. We are hard at work on a plan to support virtual-host requests to these bucket...

Bucket Policy

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

This policy allows anyone (Principal: '*') to GET all objects within the bucket (/*).

First Deploy

This is the most basic form of hosting a static site from an Amazon S3 Bucket.

Define an output for the S3 website endpoint and deploy the static site.

Outputs:
  SiteBucketDomainName:
    Value: !GetAtt SiteBucket.DomainName

The full template:

Description: Static Site
 
Resources:
  StaticSite:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    Properties:
      AccessControl: PublicRead
      BucketName: !Sub '${AWS::StackName}-site'
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: 404.html
 
Outputs:
  StaticSiteDomainName:
    Value: !GetAtt StaticSite.DomainName

Template Validation

To validate the template use the CloudFormation Linter.

cfn-lint template.yaml`

Stack Deployment

Create the AWS CloudFormation stack:

aws cloudformation create-stack \
--stack-name <NAME> \
--template-body file://template.yaml

Site Contents Deployment

Note: This the value provided to BucketName

aws s3 sync www s3://<STACK-NAME>-site

Navigate to the bucket's domain name to see the results.

TODO: add ways to retrieve the endpoint

Limitations of Website Configuration

This creates an public bucket and the domain name provided by Amazon S3 is not easy to grok or share. This solution will suffice for simple or temporary sites that do not contain sensitive documents. This approach is effective for impromptu presentations with Hugo or showing a client a demonstration.

Domain Names & Certificates

Adding a custom domain name to a static site is dependant on the manner in which you posses the domain and it's certificates.

For the sake of this paper it is assumed that domains are managed with Amazon Route 53.

Parameters

Adding parameters makes the deployment of statics sites repeatable. Start by defining a domain for the new static site.

Define a domain name to use for each site.

Parameters:
  DomainName:
    Description: Domain name of the static site
    Type: String

Further reading on extending parameters.

Certificate Validation

To use TLS we must provide a valid ViewerCertificate to the Amazon CloudFront DistDistribution. Certificates can be uploaded to AWS Certificate Manager but is outside the scope of this paper.

To obtain a valid certificate from AWS include the following in the Resources section of template.yaml.

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

AWS CloudFormation stacks will remain in the CREATE_IN_PROGRESS state until adding the appropriate CNAME record to your DNS configuration[0].

Limitations of Automating DNS Validation

AWS CloudFormation will only output the Name and Value for root domain and any sub domains need to be manually validated[0] in the console. Although it makes for a messy template, my preferred strategy is to validate a single certificate to include the wild card alias for the domain. e.g. *.domain.com

In that case a second parameter for that certificate's Amazon Resource Name (ARN) will need to be defined. Using a hard-coded value will suffice.

AWS CloudFormation Distributions

Domain names and certificates aren't enough. We will use a distribution to associate our bucket to our domain name via the Viewer Certificate.

SPA or Subdirectories

Amazon CloudFront does not return the root object from subdirectories[0]. This does not pose a problem for SPAs.

In the case of a single page application (SPA) we restrict access to the bucket using an Amazon CloudFront origin access identity (OAI). We will see that when hosting a static site with subdirectories, like a Hugo site, we leave the AccessControl on the bucket as public-read.

Subdirectory Distribution

Distributions could be their own paper. Distributions are defined within the Resources property of template.yaml.

SiteDistribution:
  Type: AWS::CLoudFront::Distribution
  Properties:
    DistributionConfig:
      Enabled: true
      DefaultRootObject: index.htm
      HttpVersion: http2
      IPV6Enabled: true

The remaining DistributionConfig properties in detail:

Aliases - the custom endpoint to use for the distribution.

Aliases:
  - !Ref DomainName

Example with sub-domain.

Aliases:
  - !Sub 'www.${DomainName}'

CustomErrorResponses - inform distribution how to handle errors.

CustomErrorResponses:
  - ErrorCachingMinTTL: 60
    ErrorCode: 404
    ResponseCode: 404
    ResponsePagePath: '/404.html'

Origins - the source of the distribution

Origins:
  - Id: !Sub 'S3-${AWS::StackName}-site'
    DomainName: !GetAtt SiteBucket.DomainName
    CustomOriginConfig:
      HTTPPort: 80
      HTTPSPort: 443
      OriginKeepaliveTimeout: 5
      OriginProtocolPolicy: http-only
      OriginReadTimeout: 30
      OriginSSLProtocols:
        - TLSv1
        - TLSv1.1
        - TLSv1.2

Take note of the OriginProtocolPolicy. In order to surface the index document of subdirectories we must respect the S3 protocol policy. We rely on the DefaultCacheBehavior's ViewerProtocolPolicy to redirect to https. The caveat with this approach is that the S3 website endpoint will remain public.

DefaultCacheBehavior - inform the distribution how long to cache items

DefaultCacheBehavior:
  TargetOriginId: !Sub 'S3-${AWS::StackName}-site'
  ViewerProtocolPolicy: redirect-to-https
  AllowedMethods:
    - GET
    - HEAD
  CachedMethods:
    - GET
    - HEAD
  Compress: true
  MaxTTL: 31536000
  SmoothStreaming: false
  DefaultTTL: 86400
  ForwardedValues:
    QueryString: true
    Cookies:
      Forward: none

This block instructs the distribution to cache GET and HEAD requests. We also inform the distribution of the cache's target origin. We request that the assets be compressed. This distribution does not serve video and instruct the distribution to turn off stream smoothing. Finally we instruct the distribution to forward query strings but ignore cookies.

ViewerCertificate

ViewerCertificate:
  AcmCertificateArn: !Ref SiteCertificate
  MinimumProtocolVersion: TLSv1.1_2016
  SslSupportMethod: sni-only

SPA Distribution

As mentioned above, we are able to restrict access to our site's bucket by creating and using the Amazon CloudFront OAI.

SiteOriginIdentity:
  Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
  Properties:
  CloudFrontOriginAccessIdentityConfig:
    Comment: !Sub 'Site Origin Identity for ${DomainName}'

Update SiteBucket's AccessControl property the now that access will be restricted to Amazon CloudFront. While we're at it, we can remove the WebsiteConfiguration since the distribution defines the root object and custom errors.

The static site bucket should be as follows:

StaticSite:
  Type: AWS::S3::Bucket
  DeletionPolicy: Delete
  Properties:
    AccessControl: BucketOwnerFullControl
    BucketName: !Sub '${AWS::StackName}-site'

Update the site bucket policy principal to reflect the Amazon CloudFront OAI user. This makes for an ugly template

TODO: Investigate a more elegant way to provide the OAI user string.

SiteBucketPolicy:
  Type: AWS::S3::BucketPolicy
  Properties:
    Bucket: !Ref SiteBucket
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action: s3:GetObject
          Resource: !Sub '${SiteBucket.Arn}/*'
          Principal:
            AWS: !Sub 'arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${SiteOriginIdentity}'

Replace the SiteDistribution's Origins CustomOriginConfig with the S3OriginConfig as follows.

Origins:
  - Id: !Sub 'S3-${AWS::StackName}-site'
    DomainName: !GetAtt SiteBucket.DomainName
    S3OriginConfig:
      OriginAccessIdentity:
        !Sub 'origin-access-identity/cloudfront/${SiteOriginId}'

Lesson Learned

SPAs restrict bucket access to a CloudFront Origin Identity. Distributions that need to surface the root object of a subdirectories require a custom origin configuration (CustomOriginConfig).

Routing

Although it makes for a larger template, the distribution configurations for many website buckets can be contained in a single file. Create an Amazon Route 53 Record Set Group to coordinate multiple sub domains.

The example below defines two records. The base domain name and a blog sub-domain endpoint.

The Hosted Zone Id for Amazon CloudFront is fixed.

Specify Z2FDTNDATAQYW2. This is always the hosted zone ID when you create an alias record that routes traffic to a CloudFront distribution.

  Route53RecordSetGroup:
    Type: AWS::Route53::RecordSetGroup
    Properties:
      HostedZoneName: !Ref DomainName
      RecordSets:
        - Name: !Ref DomainName
          Type: A
          AliasTarget:
            DNSName: !GetAtt SiteDistribution.DomainName
            EvaluateTargetHealth: false
            HostedZoneId: Z2FDTNDATAQYW2
        - Name: !Sub 'blog.${DomainName}.'
          Type: A
          AliasTarget:
            DNSName: !GetAtt BlogDistribution.DomainName
            EvaluateTargetHealth: false
            HostedZoneId: Z2FDTNDATAQYW2

Outputs

Outputs are optional. They are used here to provide the necessary information for make tasks.

We will inform a publish task of the bucket name for publishing. In the same task we will invalidate the cache distribution to force the new static contents to be served.

Continuing with our blog sub-domain example:

  SiteBucketName:
    Description: Name of site bucket
    Value: !Ref SiteBucket
  SiteDistributionID:
    Description: ID for site distribution
    Value: !Ref SiteDistribution
 
  BlogBucketName:
    Description: Name of blog bucket
    Value: !Ref BlogBucket
  BlogDistributionID:
    Description: ID for blog distribution
    Value: !Ref BlogDistribution

Makefile

lint:
    cfn-lint template.yaml
 
validate: lint
    aws cloudformation validate-template --template-body file://template.yaml
 
deploy: validate
    aws cloudformation deploy \
      --stack-name stack-name \
      --template-file template.yaml \
      --parameter-overrides DomainName=example.com
 
build:
    cd blog && hugo && cd -
 
blog: build
    aws s3 sync notes/public s3://stack-name-blog --acl public-read && \
        aws cloudfront create-invalidation \
        --distribution-id <BlogDistributionID> \
        --paths "/*"
 
site:
  aws s3 cp index.html s3://stack-name-site --acl public-read && \
        aws cloudfront create-invalidation \
        --distribution-id <SiteDistributionID> \
        --paths "/*"
 
all: blog site

The full example can be found on github.

Main Source:

static sites with hugo and cloudformation


See all posts