AWS Static Site: CloudFormation
Table of Contents - Infrastructure as Code #
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 #
- An AWS account
- A Route53 hosted zone
- AWS CLI
- AWS Access Keys
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.com
1
vs.
https://<bucket-name>.s3.<Region>.amazonaws.com
Website endpoints have a few limitations:
- they do not support HTTPS
- in order to use a CNAME to give our bucket a friendlier DNS name, the bucket name and the CNAME must match2
- 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 toPrivate
. There is a predefinedPublic
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:
- create-stack : create a new stack; will error out if the stack exists - this means that we need to switch to a different command to update a stack after it's created.
- update-stack : calculate the differences with the existing stack and immediately apply the changes; will error out if the stack doesn't exist - this means that not only do we have to switch between commands for creating/updating stacks, there is no opportunity to preview the changes that will be made
- create-change-set : calculate the differences with the existing stack and preview them as a change set without applying the changes; will error out if the stack doesn't exist (unless you specify
--change-set-type CREATE
) - while we can now preview changes before they are applied (really important!) we still have to switch betweenCREATE
andUPDATE
for the change set type to handle new vs. existing stacks - execute-change-set : apply the changes from an existing change set; will error out if the stack or change set do not exist - this command also has some interesting gotchas, if a change set is created but not applied for a long time, someone else could modify the stack before the original person comes back to execute their changeset yielding unexpected results
- deploy : create and apply a changeset for new or existing stacks - this command is far more convenient for scripting since it will handle new vs. existing stacks; however, it will immediately apply the changeset preventing the ability to preview the change, unless you pass
--no-execute-changeset
- in this case you can view the change, but then will need to switch to the execute-change-set command to get your changes applied
#!/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
-
In
us-east-1
. In other regions the dash before the region could be a dothttp://<bucket-name>.s3-website.<Region>.amazonaws.com
↩︎ -
https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#VirtualHostingCustomURLs ↩︎
-
https://aws.amazon.com/premiumsupport/knowledge-center/s3-public-access-acl/ ↩︎
-
https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteAccessPermissionsReqd.html ↩︎