Deploying static websites to AWS
TL;DR Grab the three template files (example.com.yaml
, static-site.yaml
, domain-redirection.yaml
), replace references to example.com
with your domain, and save them to a cloudformation
folder. Grab the deployment script and save it to a scripts
folder. Run aws package
and aws deploy
once when setting up the site for the first time, and then ./scripts/deploy.sh
every time you want to update your site. All in all it will cost you around ¢50 a month.
Most of my personal projects take the form of a static website. I typically buy a domain name and want the URL to be:
https://www.example.com
But I also want all these variations to redirect to the above:
http://example.com
http://www.example.com
https://example.com
I haven’t had a personal VPS in a very long time because the one company I liked was acquired by GoDaddy, so I didn’t have a good place to host these. I looked into options and landed on AWS for a few reasons:
- I used to work at Amazon and have a lot of familiarity with AWS.
- If my website becomes popular there is no server to baby-sit.
- SSL certificates are free, easy to add, and they renew themselves.
- AWS is a reliable company that will stick around for the foreseeable future.
- It’s not too hard to add a backend on the same platform it it becomes necessary.
So over the years I have been refining a set of CloudFormation templates that let me deploy this sort of website very simply. There was a lot of trial (writing some YAML) and error (waiting for 5 minutes for AWS to tell me what I did wrong) involved and I’m sharing them in the hopes nobody else has to deal with this sort of thing again.
Overview
All in all, what gets deployed is a CloudFormation stack comprised of two Sub-Stacks:
- The StaticSite stack is the fully-functional
https://www.example.com
website, plushttp
→https
redirection. - The ApexRedirect stack takes care of redirecting
https://example.com
to the above, plushttp
→https
redirection.
I represent that in three files: example.com.yaml
, static-site.yaml
and domain-redirection.yaml
. Here is what example.com.yaml
looks like:
# example.com.yaml
AWSTemplateFormatVersion: 2010-09-09
Resources:
StaticSite:
Type: AWS::CloudFormation::Stack
Properties:
TemplateURL: "static-site.yaml"
Parameters:
DomainName: example.com
SubDomain: www
ApexRedirect:
Type: AWS::CloudFormation::Stack
Properties:
TemplateURL: "domain-redirection.yaml"
Parameters:
FromDomain: example.com
ToDomain: www.example.com
SSLCertificateArn: !GetAtt StaticSite.Outputs.SSLCertificateArn
HostedZoneId: !GetAtt StaticSite.Outputs.HostedZoneId
Outputs:
NameServers:
Description: Name servers to set in your domain registrar.
Value: !GetAtt StaticSite.Outputs.NameServers
The output of the stack is a list of DNS name servers. I buy my domains with Google, so I have to go there and set them up during first time deployment, more on that below.
The StaticSite
stack
The goal of this stack is to serve a set of static files from an S3 bucket on a particular domain, protected with SSL. I want to do that cheaply, and as hands-off as possible.
Part of being hands-off is avoiding lambda functions. AWS doesn’t have anything as fully featured as Apache or Nginx, and they encourage you to use lambda functions to fill in the gaps. That introduces custom code and an unbounded cost source that might be a scaling problem in the future. So even if it makes my template a little more complicated, I try to rely on off-the-shelf AWS services as much as possible.
Here’s what that looks like:
- The site bucket is a private S3 bucket that will contain our static assets. S3 is cheap, uploading to it is straight-forward and it is very HTTP-friendly so you can set up stuff like cache headers very easily.
- The CloudFront distribution is a CDN that will actually receive traffic from the internet. S3 is capable of doing that on its own (with a little more latency), but I picked CloudFront because you can’t attach an SSL certificate to an S3 bucket directly.
- The SSL certificate is exactly that. It’s fully managed by AWS Certificate Manager so they take care of seamlessly renewing it every year. It’s also free when you use it with CloudFront! It’s configured to do DNS-based domain validation, so you just have to set up the DNS records in your domain registrar and it will validate automatically.
- The Hosted Zone is how AWS Route53 manages DNS records. It points an A record to the CloudFront distribution, so queries are free and you just pay ¢50/month for the zone itself.
As of this writing, even when funnyhowtheknightmoves.com made it to the front page of Hacker News and had 20K visits in a day I was only paying approximately ¢53 per month, so it’s a much cheaper set up than my old VPS or even a traditional AWS Amplify site.
The template includes some glue to pull these elements together. Notably:
- A bucket policy that explicitly allows the distribution to read from the bucket. That lets us to keep the bucket private, so the only way for the internet to look into it is through the distribution. To accomplish that I had to declare an explicit CloudFront Origin Access Identity because there is no way to
!GetAtt
it from the distribution resource. - A CloudFront origin (and a default cache behavior) to configure exactly how files are requested and cached from S3. This is analogous to Apache Directory Directives, or Nginx origin config, but not as fully-featured. I’m using a managed cache policy provided by AWS that fits well with our S3 bucket model, although in practice all the caching is explicitly configured by my upload script.
- A Record Set that aliases the domain to the CloudFront distribution. It does that by delegating to a Hosted Zone that is managed by AWS, its ID is always
Z2FDTNDATAQYW2
.
This is what the full static-site.yaml
template looks like:
# static-site.yaml
AWSTemplateFormatVersion: 2010-09-09
Parameters:
DomainName:
Type: String
Description: Apex domain for the site. E.g. example.com.
SubDomain:
Type: String
Description: Canonical subdomain, e.g. www. Leave blank for using apex.
Conditions:
IsNakedDomain: !Equals [!Ref SubDomain, ""]
Resources:
SiteBucket:
Type: "AWS::S3::Bucket"
Properties:
AccessControl: Private
BucketName:
!If [IsNakedDomain, !Ref DomainName, !Sub "${SubDomain}.${DomainName}"]
SiteBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref SiteBucket
PolicyDocument:
Statement:
- Effect: Allow
Action:
- s3:GetObject
Resource: !Sub "${SiteBucket.Arn}/*"
Principal:
CanonicalUser: !GetAtt SiteDistributionAccessIdentity.S3CanonicalUserId
SiteDistributionAccessIdentity:
Type: "AWS::CloudFront::CloudFrontOriginAccessIdentity"
Properties:
CloudFrontOriginAccessIdentityConfig:
Comment: !Sub "Access identity for ${DomainName} distribution"
SiteDistribution:
Type: "AWS::CloudFront::Distribution"
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
HttpVersion: http2
Origins:
- DomainName: !GetAtt SiteBucket.DomainName
Id: !Ref SiteBucket
S3OriginConfig:
OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${SiteDistributionAccessIdentity}"
DefaultCacheBehavior:
TargetOriginId: !Ref SiteBucket
ViewerProtocolPolicy: redirect-to-https
Compress: true
# CachingOptimizedForUncompressedObjects
CachePolicyId: b2884449-e4de-46a7-ac36-70bc7f1ddd6d
Aliases:
- !If [
IsNakedDomain,
!Ref DomainName,
!Sub "${SubDomain}.${DomainName}",
]
ViewerCertificate:
AcmCertificateArn: !Ref SSLCertificate
SslSupportMethod: sni-only
SSLCertificate:
Type: "AWS::CertificateManager::Certificate"
Properties:
DomainName: !Ref DomainName
SubjectAlternativeNames:
- !Sub "*.${DomainName}"
ValidationMethod: DNS
HostedZone:
Type: "AWS::Route53::HostedZone"
Properties:
HostedZoneConfig:
Comment: !Sub "Hosted zone for ${DomainName}"
Name: !Ref DomainName
MainRecord:
Type: "AWS::Route53::RecordSet"
Properties:
HostedZoneId: !Ref HostedZone
Name:
!If [IsNakedDomain, !Ref DomainName, !Sub "${SubDomain}.${DomainName}"]
Type: A
AliasTarget:
HostedZoneId: Z2FDTNDATAQYW2
DNSName: !GetAtt SiteDistribution.DomainName
Outputs:
NameServers:
Description: Name servers to set in your domain registrar.
Value: !Join [", ", !GetAtt HostedZone.NameServers]
SSLCertificateArn:
Description: ARN of the created SSL certificate. It covers example.com and \*.example.com.
Value: !Ref SSLCertificate
HostedZoneId:
Description: Id of the created HostedZone.
Value: !Ref HostedZone
The ApexRedirect
stack
The goal of the ApexRedirect stack is much simpler. We just want to redirect example.com
to www.example.com
, again as cheaply and hands-off as possible.
This is what it looks like:
Refer to the section above for a detailed explanation, but in general:
- The S3 bucket is public but empty. All it does is return a redirect to the main domain. It’s only necessary because redirects can’t be configured directly on CloudFront. I could use a Lambda instead but you know how I feel about those.
- The CloudFront distribution is going to cache and serve that redirect. It’s only necessary because SSL certificates can’t be attached to an S3 bucket directly.
- A record set is created in the same Hosted Zone as the main site.
- The same certificate as the main site is re-used for this domain.
This is what the full domain-redirection.yaml
template looks like:
# domain-redirection.yaml
AWSTemplateFormatVersion: 2010-09-09
Parameters:
FromDomain:
Type: String
Description: Origin domain name to redirect, e.g. example.com
ToDomain:
Type: String
Description: Destination domain name, e.g. www.example.com
SSLCertificateArn:
Type: String
Description: Arn for an SSL certificate that covers the origin domain
HostedZoneId:
Type: String
Description: HostedZone to create record for the origin domain
Resources:
RedirectBucket:
Type: "AWS::S3::Bucket"
Properties:
AccessControl: PublicRead
BucketName: !Ref FromDomain
WebsiteConfiguration:
RedirectAllRequestsTo:
HostName: !Ref ToDomain
Protocol: https
RedirectDistribution:
Type: "AWS::CloudFront::Distribution"
Properties:
DistributionConfig:
Enabled: true
Origins:
- DomainName:
!Select [2, !Split ["/", !GetAtt RedirectBucket.WebsiteURL]]
Id: !Ref RedirectBucket
CustomOriginConfig:
HTTPPort: "80"
HTTPSPort: "443"
OriginProtocolPolicy: http-only
DefaultCacheBehavior:
TargetOriginId: !Ref RedirectBucket
ForwardedValues:
QueryString: false
ViewerProtocolPolicy: allow-all
Aliases:
- !Ref FromDomain
ViewerCertificate:
AcmCertificateArn: !Ref SSLCertificateArn
SslSupportMethod: sni-only
RedirectRecord:
Type: "AWS::Route53::RecordSet"
Properties:
HostedZoneId: !Ref HostedZoneId
Name: !Ref FromDomain
Type: A
AliasTarget:
HostedZoneId: Z2FDTNDATAQYW2
DNSName: !GetAtt RedirectDistribution.DomainName
Deployment
Deployment has two stages: deploying the CloudFormation template, and deploying your assets. The CloudFormation template deployment only needs to happen once, when you first set up the site. And then every time you update the website and change your static assets you just copy the files to the S3 Bucket.
CloudFormation template deployment
You need to have the aws cli installed. Configure your credentials with aws configure
. I have a special user with a restricted set of permissions to avoid having root AWS credentials laying around in my hard drive. The most minimal (but still practical) policy I have been able to come up with is:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CloudFormationGeneral",
"Effect": "Allow",
"Action": [
"cloudformation:ValidateTemplate",
"cloudformation:GetTemplateSummary",
"cloudformation:DescribeChangeSet",
"cloudformation:ExecuteChangeSet",
"cloudformation:DescribeStacks",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResources",
"cloudformation:DeleteStack"
],
"Resource": "*"
},
{
"Sid": "CloudFormationResources",
"Effect": "Allow",
"Action": [
"cloudformation:CreateStack",
"cloudformation:CreateChangeSet",
"cloudformation:UpdateStack"
],
"Resource": "*",
"Condition": {
"ForAllValues:StringLike": {
"cloudformation:ResourceTypes": [
"AWS::Route53::HostedZone",
"AWS::Route53::RecordSet",
"AWS::CertificateManager::Certificate",
"AWS::CloudFront::Distribution",
"AWS::S3::Bucket",
"AWS::IAM::Policy"
]
}
}
},
{
"Sid": "ResourceCreation",
"Effect": "Allow",
"Action": [
"cloudfront:*",
"route53:*",
"acm:*",
"s3:*"
],
"Resource": "*"
}
]
}
I deploy all of my websites to us-east-1
because a few years ago SSL certificates could only be provisioned there. I’m not sure that is still the case, let me know if you successfully deploy to other regions.
You also need an artifacts S3 Bucket where AWS is going to upload intermediate files. All your sites can share the same one. Just manually make a normal private bucket, and maybe configure the object lifecycle to clean it up periodically.
Place the three files (example.com.yaml
renamed to whatever your domain is, static-website.yaml
and domain-redirection.yaml
) in a cloudformation
directory at the root of your project. Make sure to replace all references to example.com
in example.com.yaml
with your own domain.
When it’s time to deploy, go to the cloudformation
directory and first package the template with the following command (replace the template, output template and artifacts-bucket
names with your own):
$ aws cloudformation package --template-file example.com.yaml \
--s3-bucket artifacts-bucket \
--output-template-file packaged-example.com.yaml
And then deploy the stack (replace the packaged template and stack names with your own):
$ aws cloudformation deploy --template-file packaged-example.com.yaml \
--stack-name example-com \
--capabilities CAPABILITY_IAM
Now go check the CloudFormation section of the AWS Console and click on your stack. In the Events tab you will see how resources are being created.
If you are using an external domain provider (I personally use Google Domains) there’s a little bit of manual setup required for the stack creation to finish successfully:
- Wait until the SSLCertificate resource is in the Creation in progress state.
- Go to the Route 53 Console and click on the Hosted Zone for your site.
- Find the
NS
record, and make note of the list of domain servers. Notice that they all end with a dot, don’t forget to include it when copying and pasting. - Create an
NS
record in your domain registrar. For Google this entails switching to manual DNS and just adding the four servers in a list. - Finally go to AWS certificate manager, find the certificate for your new site, and click the Create Records in Route53 button.
Once the domain propagates the ACM validator will see the validation record in DNS and it will exit pending status and proceed to deploy the full template.
You almost have a website now!
Asset deployment
The final step is actually uploading your assets to the S3 bucket. For personal projects I just deploy from a directory in my machine, cowboy style. I use the following script (in scripts/deploy.sh
):
#!/usr/bin/env bash
npm run build
aws s3 sync dist s3://www.pgneditor.com \
--acl private \
--delete \
--cache-control max-age=31536000,public \
--exclude "*.html"
aws s3 sync dist s3://www.pgneditor.com \
--acl private \
--delete \
--cache-control max-age=0,no-cache,no-store,must-revalidate \
--content-type text/html \
--exclude "*" --include "*.html"
- The first command just makes sure the site is built, adjust accordingly.
- Next I sync all the non-HTML assets in the local
dist
folder (your build tool might put your assets elsewhere) to thewww.example.com
S3 bucket. If you are using thestatic-site
template your bucket’s name will be the same as your full domain. I ask aws to delete any bucket files that are not in dist, and to set the cache age of all the assets to the maximum possible. - Finally I sync all of the HTML content (for most of my projects that is a single file) but I set the cache age to 0. The idea is that
index.html
is always served fresh so that I can have instant deployments.
And that’s it! Just run this script whenever you change your site and it will be deployed with no downtime.
Customization
Both sub-templates have the domain and subdomain as parameters, so you can switch it to make example.com
the primary and www.example.com
the secondary.
You might also want to deploy smaller projects as subdomains of a main domain (for instance demo.example.com
). If you already set up example.com
with these templates you can take static-site.yaml
, turn HostedZoneId
and SSLCertificateArn
into parameters, and copy their values from examples.com
’s stack.
Depending on your use case you might want to configure the CloudFront origin and the Cache behavior in different ways:
- If your asset finger-printing is based on query strings instead of changing actual file names (for instance, if you use
asset-cache-bust
) you can ask CloudFront to use that as part of the cache key:DefaultCacheBehavior: # ... ForwardedValues: QueryString: true QueryStringCacheKeys: - v
- If your site is a single page application you probably want
www.example.com/some/path
to serve yourindex.html
and let your client-side router take care of the path. You can do that with:CustomErrorResponses: - ErrorCode: 404 ErrorCachingMinTTL: 3600 ResponseCode: 200 ResponsePagePath: /index.html - ErrorCode: 403 ErrorCachingMinTTL: 3600 ResponseCode: 200 ResponsePagePath: /index.html
- For all of my sites I need
example.com
to serveindex.html
without having to typeexample.com/index.html
. For that you can set theDefaultRootObject
toindex.html
. - If you want to redirect
/path/
to/path/index.html
in general you have to do it “manually”. For this blog I use a Lambda@Edge as recommended in this article. In theDefaultCacheBehavior
section of theSiteDistribution
you need to add:
And then you need a few resources to set up the lambda:LambdaFunctionAssociation: - EventType: origin-request LambdaFunctionArn: !Ref RedirectFunctionVersion
An update to the article mentions that CloudFront functions are a more performant way of doing that, I will give them a try and report back.RedirectFunctionPolicy: Type: 'AWS::IAM::Policy' Properties: PolicyName: RedirectFunctionPolicy Roles: - !Ref RedirectFunctionRole PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - 'logs:*' Resource: - 'arn:aws:logs:*:*:*' RedirectFunctionRole: Type: 'AWS::IAM::Role' Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: Effect: Allow Principal: Service: lambda.amazonaws.com Action: - 'sts:AssumeRole' RedirectFunction: Type: 'AWS::Lambda::Function' Properties: Handler: index.handler Runtime: nodejs18.x Role: !GetAtt RedirectFunctionRole.Arn Code: Zipfile: | 'use strict'; exports.handler = (event, context, callback) => { // Extract the request from the CloudFront event that is sent to Lambda@Edge var request = event.Records[0].cf.request; // Extract the URI from the request var olduri = request.uri; if (olduri.match(/\/[^.\/]+$/)) { // Match urls with no extension or trailing slash. Redirect with slash. return callback(null, {status: 301, headers: {location: [{key: 'Location', value: olduri + '/'}]}}); } else { // Match any '/' that occurs at the end of a URI. Replace it with a default index var newuri = olduri.replace(/\/$/, '\/index.html'); } // Replace the received URI with the URI that includes the index page request.uri = newuri; // Return to CloudFront return callback(null, request); }; Timeout: 3 RedirectFunctionVersion: Type: 'AWS::Lambda::Version' Properties: FunctionName: !Ref RedirectFunction Description: Managed by CloudFormation