Create a static site on AWS with CloudFormation

How to create a static site in AWS with a custom domain, SSL certificate, and proper redirects with a single CLI command.

Create a static site on AWS with CloudFormation

A short story about Monithor's static website

click here to jump straight to the technical part

Leaving the landing page by the very end, when in my acceptance the project is "ready" to launch is a mistake I did way too many times in the past. Nearly all my abandoned projects were code-first, marketing-later.

The pattern is quite common among fellow developers: buy a domain ⟶ code like crazy for a few weeks ⟶ run out of steam / find a new project idea ⟶ abandon the project ⟶ repeat. Very few projects even have the chance to get in front of potential users.

Although this article is published as the 3rd in the series, the landing page was the first thing I did for Monithor. A landing page helps you look at things from the other side of the fence, and writing the copy gives clarity and helps better define the scope.

I wanted to avoid getting lost in details and spend a lot of time designing & coding the template for the landing page, so I went straight to HTMLRev and got a great-looking template. I highly recommend Lucian's work, crazy attention to detail, and the code quality is great.

The logo and the color palette are from logology, they have a really fun and quick process and the result is great.

I glued the pieces together, wrote the copy, and a day later I had a great-looking landing page that was ready to be deployed. I usually deploy static sites to Netlify, but as Monithor will be built entirely on top of AWS it made more sense to have it all in one place.

Screenshot 2022-03-31 at 06.45.13.png

Deploy to AWS

Deploying a static website to AWS might sound simple, but if you want a custom domain with an SSL certificate, and a proper non-www to www redirect, things get messy. It's not complicated, but it's a very cumbersome process to set it up from the AWS console, I did it so many times that I got fed up.

As a developer, fan of Infrastructure as Code (IaC), I decided to create a CloudFormation template that will save you time in the future.

The standard way

But first, let me describe the standard way of setting up a static website on AWS to understand why I want to avoid it in the future:

  1. create two S3 buckets, one for the naked domain (non-www) and one for the www
  2. enable static website hosting on buckets
  3. set the non-www bucket to redirect requests to the www domain (or the other way around)
  4. request a certificate to cover both the non-www and the www domain
  5. add CNAMES in the Route53 hosted zone to validate the domain for the certificate
  6. wait for the certificate to be issued
  7. create two CloudFront distributions, one for each of the buckets
  8. make sure each distribution Origin points to the bucket website endpoint, not the bucket URL
  9. go back to Route53 and create alias entries to point the domain to the CloudFront distros
  10. done, if we didn't make any mistake

The easy way

The CloudFormation template will save you time and frustration:

  • creates a single S3 bucket with the required policy
  • creates a single CloudFront distribution and sets the proper Origin
  • issues and installs the SSL certificate
  • automates all the required Route53 DNS records
  • manages redirects with a CloudFront Function

You simply type npm run create-stack command in the terminal and wait for the magic to happen. It will take about 5 minutes to complete though, you can monitor the progress from the AWS Console

You just need to:

  • have the domain purchased and pointed to the hosted zone created for it in AWS Route53 before creating the stack
  • have AWS CLI installed and a user with AdministratorAccess

Create the stack

The create-stack command is as below:

aws cloudformation create-stack \
--stack-name STACK_NAME \
--template-body file://template.yml \
--profile AWS_PROFILE \
--region us-east-1 \
--parameters ParameterKey=DomainName,ParameterValue=DOMAIN_NAME ParameterKey=HostedZoneId,ParameterValue=HOSTED_ZONE_ID

Where

  • STACK_NAME - is your stack name
  • AWS_PROFILE - is your credentials profile in the
  • DOMAIN_NAME - is your domain name (the naked domain, e.g. example.com)
  • HOSTED_ZONE_ID - is your Route53 zone for the domain, should look like Z07414553OA4KK51T5EZA

Deploy the site

The deploy command will deploy your static site to the S3 bucket and invalidate the CloudFront cache. You may update as per your needs, by default the copy command excludes all paths in the current folder, in order to prevent deploying sensitive info.

aws s3 cp ./ s3://S3_BUCKET_NAME \
--acl public-read \
--profile AWS_PROFILE \
--recursive \
--exclude "*" \
--include index.html \
--include 404.html \
&& aws cloudfront create-invalidation \
--profile monithor \
--distribution-id DISTRIBUTION_ID \
--paths "/*"

Where:

  • S3_BUCKET_NAME - is your S3 bucket name
  • AWS_PROFILE - is your credentials profile
  • DISTRIBUTION_ID - is your CloudFront distribution ID

And here's the CloudFormation template:

Description: Deploy a static site

Parameters:
    DomainName:
        Description: Domain name
        Type: String
    HostedZoneId:
        Description: Hosted Zone ID
        Type: String

Resources:
    S3Bucket:
        Type: AWS::S3::Bucket
        DeletionPolicy: Delete
        Properties:
            AccessControl: PublicRead
            BucketName: !Sub "${AWS::StackName}"
            WebsiteConfiguration:
                ErrorDocument: "404.html"
                IndexDocument: "index.html"

    S3BucketPolicy:
        Type: AWS::S3::BucketPolicy
        Properties:
            Bucket: !Ref S3Bucket
            PolicyDocument:
                Version: "2012-10-17"
                Statement:
                    - Effect: "Allow"
                      Action: "s3:GetObject"
                      Principal: "*"
                      Resource: !Sub "${S3Bucket.Arn}/*"
    #
    CertificateManagerCertificate:
        Type: AWS::CertificateManager::Certificate
        Properties:
            # naked domain
            DomainName: !Ref DomainName
            # add www to certificate
            SubjectAlternativeNames:
                - !Sub "www.${DomainName}"
            ValidationMethod: DNS
            DomainValidationOptions:
                # DNS record for the naked domain
                - DomainName: !Ref DomainName
                  HostedZoneId: !Ref HostedZoneId
                # DNS record for the www domain
                - DomainName: !Sub "www.${DomainName}"
                  HostedZoneId: !Ref HostedZoneId
    #
    CloudFrontDistribution:
        Type: AWS::CloudFront::Distribution
        Properties:
            DistributionConfig:
                Aliases:
                    - !Ref DomainName
                    - !Sub "www.${DomainName}"
                CustomErrorResponses:
                    - ErrorCachingMinTTL: 60
                      ErrorCode: 404
                      ResponseCode: 404
                      ResponsePagePath: "/404.html"
                DefaultCacheBehavior:
                    AllowedMethods:
                        - GET
                        - HEAD
                    CachedMethods:
                        - GET
                        - HEAD
                    Compress: true
                    DefaultTTL: 86400
                    ForwardedValues:
                        Cookies:
                            Forward: none
                        QueryString: true
                    MaxTTL: 31536000
                    SmoothStreaming: false
                    TargetOriginId: !Sub "S3-${AWS::StackName}"
                    ViewerProtocolPolicy: "redirect-to-https"
                    FunctionAssociations:
                        - EventType: viewer-request
                          FunctionARN: !GetAtt RedirectFunction.FunctionMetadata.FunctionARN
                DefaultRootObject: "index.html"
                Enabled: true
                HttpVersion: http2
                IPV6Enabled: true
                Origins:
                    - CustomOriginConfig:
                          HTTPPort: 80
                          HTTPSPort: 443
                          OriginKeepaliveTimeout: 5
                          # keep http-only to avoid 504 errors after stack creation
                          OriginProtocolPolicy: "http-only"
                          OriginReadTimeout: 30
                          OriginSSLProtocols:
                              - TLSv1
                              - TLSv1.1
                              - TLSv1.2
                      #Bucket website endpoint without http://
                      DomainName: !Join
                          - ""
                          - - !Ref S3Bucket
                            - ".s3-website-"
                            - !Ref AWS::Region
                            - ".amazonaws.com"
                      Id: !Sub "S3-${AWS::StackName}"
                PriceClass: PriceClass_All
                ViewerCertificate:
                    AcmCertificateArn: !Ref CertificateManagerCertificate
                    MinimumProtocolVersion: TLSv1.1_2016
                    SslSupportMethod: sni-only

    RedirectFunction:
        Type: AWS::CloudFront::Function
        Properties:
            AutoPublish: true
            Name: !Sub "${AWS::StackName}-redirects"
            # add the config, even if optional, the stack creation will thrown InternalFailure error otherwise
            FunctionConfig:
                Comment: !Sub "Redirect to ${DomainName}"
                Runtime: cloudfront-js-1.0
            FunctionCode: !Sub |
                function handler(event) {
                    //
                    var request = event.request;
                    var host = request.headers.host.value;

                    if (!host.startsWith("www.")) {
                        return {
                            statusCode: 301,
                            statusDescription: "Permanently moved",
                            headers: {
                                location: { value: "https://www." + host },
                            },
                        };
                    }
                    return request;
                }

    Route53RecordSetGroup:
        Type: AWS::Route53::RecordSetGroup
        Properties:
            # keep the . suffix
            HostedZoneName: !Sub "${DomainName}."
            # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-aliastarget.html#cfn-route53-aliastarget-hostedzoneid
            RecordSets:
                - Name: !Ref DomainName
                  Type: A
                  AliasTarget:
                      DNSName: !GetAtt CloudFrontDistribution.DomainName
                      EvaluateTargetHealth: false
                      HostedZoneId: Z2FDTNDATAQYW2 # leave hardcoded, don't confuse w/ !Ref HostedZoneId
                - Name: !Sub "www.${DomainName}"
                  Type: A
                  AliasTarget:
                      DNSName: !GetAtt CloudFrontDistribution.DomainName
                      EvaluateTargetHealth: false
                      HostedZoneId: Z2FDTNDATAQYW2 # leave hardcoded, don't confuse w/ !Ref HostedZoneId

Outputs:
    WebsiteURL:
        Value: !GetAtt S3Bucket.WebsiteURL
        Description: URL for website hosted on S3
    CloudfrontDomainName:
        Value: !GetAtt CloudFrontDistribution.DomainName

Truth be told, it looks much more complex than it is. I also created a GitHub repository with the template so you can get started quicker. Check it out.

Credits

About

Monithor is a web service monitoring tool that helps you check websites or APIs' uptime and functionality and be alerted when they don't work as expected. It's built on AWS using the Serverless framework and it's open-source