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: