Jair Trejo

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, plus httphttps redirection.
  • The ApexRedirect stack takes care of redirecting https://example.com to the above, plus httphttps 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:

  1. Wait until the SSLCertificate resource is in the Creation in progress state.
  2. Go to the Route 53 Console and click on the Hosted Zone for your site.
  3. 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.
  4. 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.
  5. 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 the www.example.com S3 bucket. If you are using the static-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 your index.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 serve index.html without having to type example.com/index.html. For that you can set the DefaultRootObject to index.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 the DefaultCacheBehavior section of the SiteDistribution you need to add:
    LambdaFunctionAssociation:
      - EventType: origin-request
        LambdaFunctionArn: !Ref RedirectFunctionVersion
    
    And then you need a few resources to set up the lambda:
    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
    
    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.