AWS Serverless Static Site

As a first blog post, I thought it would be a good idea to talk about how I built the Cloud infrastructure that hosts it, a Hugo generated static site hosted on Amazon S3 and CloudFront. I quite like the idea of using Static Site Generators like Hugo, Jekyll or Gatsby. Although they sometimes get a bad rap for being a bit bloated, I think they are just fine for achieving my goal: A simple way to push out content.

When it comes to website hosting platforms, Amazon Web Services allows you to create a variety of different architectures, from the traditional Web Server running on an Amazon EC2 instances to a Serverless static site running in S3. In my case, I chose the later as it allowed me to create a simple, cost-effective, secure, globally available and scalable way to host my website.

“Serverless allows you to build and run applications and services without thinking about servers. It eliminates infrastructure management tasks such as server or cluster provisioning, patching, operating system maintenance, and capacity provisioning”. — Amazon Web Services

The primary AWS services used in my infrastructure are Amazon Route 53, AWS Certificate Manager, Amazon CloudFront, AWS Lambda, and Amazon S3. But It is important to note that you don’t need all these services to host a Serverless static website in AWS. In fact, that is covered by Amazon S3 alone, which can give you an HTTP endpoint for a static site. Below I will outline the steps needed fulfil all the requirements. All related CloudFormation templates and code can be found in this Github Repository.

The first step of any website is to first get a Domain name, which you will need to purchase from a Domain Registrar. Amazon’s Route 53 service offers a variety of Top Level Domains (“.com”, “.net”, “.cloud”, etc…) to purchase, and when you do it will create a Hosted Zone for you. However, it does not support all TLD’s and in my case, I had to purchase “somecloud.dev” from another Registrar. In doing this it was necessary for me to create my own Hosted Zone for my domain, which I achieved using CloudFormation. After it’s created you will need to copy the four Name Server values and replace your 3rd party Domain Registrar’s values, such as Google Domains or Namecheap.

# Deploy Command
aws cloudformation deploy \
  --template-file hosted-zones.yaml \
  --stack-name mysite-hosted-zones \
  --parameter-overrides \
  DomainName=YOUR_SITE.com
# hosted-zones.yaml
AWSTemplateFormatVersion: '2010-09-09'

Description: Hosted Zones for domain in another Domain Registrar

Parameters:
  DomainName:
    Type: String
    Description: The DNS name of an existing Amazon Route 53 hosted zone e.g. somecloud.dev

Resources:
  HostedZone:
    Type: "AWS::Route53::HostedZone"
    Properties:
      Name: !Sub '${DomainName}.'

Outputs:
  HostedZoneId:
    Value: !Ref HostedZone
    Export:
      Name: !Sub '${AWS::StackName}-HostedZoneId'

Create an ACM Certificate for SSL

AWS Certificate Manager enabled me to create a public SSL/TLS Certificate to use this the website. This is provided for free by AWS and the certificate can contain one or more domains. Even if you don’t have any other domains names you want to add to the certificate, it can be handy to future-proof your architecture by adding an additional “*” Subdomain.

As an example, a ACM Certificate which includes both “somecloud.dev” and “*.somecloud.dev” would allow for unlimited additional subdomains at the first level, such as “www”. This can all be provisioned via the CloudFormation below even though there is a manual human element in the process, DNS Record Approval, which will be automatic in your hosted zone. So our certificate can be used CloudFront in a future step, it is very important it is deployed to the “us-east-1” AWS region!. This will usually take around 15 minutes, so it’s a good time to walk a way from the computer for a bit and make a cup of coffee!

# Deploy Command
aws cloudformation deploy \
  --template-file certificate.yaml \
  --stack-name mysite-certificate  \
  --region us-east-1 \
  --parameter-overrides \
  DomainName=YOUR_SITE.com \
  AlternativeDomainNames=*.YOUR_SITE.com
# certificate.yaml
AWSTemplateFormatVersion: '2010-09-09'

Description: ACM Wildcard Certificate Template

Parameters:
  DomainName:
    Type: String
    Description: TThe primary Domain for the certificate
    AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
    ConstraintDescription: must be a valid DNS zone name

  AlternativeDomainNames:
    Type: CommaDelimitedList
    Default: ''

Resources:
  certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref DomainName
      SubjectAlternativeNames: !Ref AlternativeDomainNames
      ValidationMethod: DNS

Outputs:
  Certificate:
    Description: 'AWS Certificate Manager (ACM) certificate'
    Value: !Ref certificate
    Export:
      Name: !Sub '${AWS::StackName}-Certificate'

Create an AWS Lambda function for CloudFront Origin Requests

When hosting a Serverless static website there can be a few quirks and annoyances that you wouldn’t normally experience on a standard web application server. In our case the default values for the website path can become broken when looking for the origin resources. This mean’s that most modern web frameworks that use default directory index pathing (“/posts/” will get resource “/posts/index.html”) will get an error response. This is a result of linking the CloudFront distribution to the S3 Bucket that hosts the static content via by its name origin instead of its website endpoint.

To solve this we create an AWS Lambda function that that is used by CloudFront via Lambda@Edge to interpret what items to cache from origin for a request. The function will match a user’s requests to an index file if they provide an end path without an index file, as outlined by this blog post by AWS. Also like our certificate must be deployed to the “us-east-1” AWS region!

# Deploy Command
aws cloudformation deploy \
  --template-file redirect-index.yaml \
  --stack-name mysite-redirect-index  \
  --capabilities CAPABILITY_IAM \
  --region us-east-1
# redirect-index.yaml
AWSTemplateFormatVersion: 2010-09-09

Description: Lambda@Edge function for CloudFront origin request redirection of path "/" to index file

Parameters:
  IndexFile:
    Type: String
    Description: The default root index file for any path
    Default: index.html

Resources:
  LambdaEdgeFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Sid: "AllowLambdaServiceToAssumeRole"
          Effect: "Allow"
          Action:
            - "sts:AssumeRole"
          Principal:
            Service:
              - "lambda.amazonaws.com"
              - "edgelambda.amazonaws.com"

  LambdaEdgeFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Role: !GetAtt LambdaEdgeFunctionRole.Arn
      Runtime: nodejs8.10
      MemorySize: 128
      Timeout: 5
      Code:
        # If you change the ZipFile, rename the logical id LambdaVersionV5 to trigger a new version creation!
        ZipFile: !Sub |
          '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;

            // Match any '/' that occurs at the end of a URI. Replace it with a default index
            var newuri = olduri.replace(/\/$/, '\/${IndexFile}');

            // Replace the received URI with the URI that includes the index page
            request.uri = newuri;

            // Return to CloudFront
            return callback(null, request);
          };

  LambdaEdgeFunctionVersion:
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !Ref LambdaEdgeFunction

Outputs:
  StackName:
    Description: Stack name.
    Value: !Sub ${AWS::StackName}

  RedirectionFunction:
    Description: ARN of the redirection Lambda function
    Value: !Ref LambdaEdgeFunction
    Export:
      Name: !Sub ${AWS::StackName}-RedirectionFunction

  RedirectionFunctionVersion:
    Description: ARN of the redirection Lambda function version for CloudFront
    Value: !Ref LambdaEdgeFunctionVersion
    Export:
      Name: !Sub ${AWS::StackName}-RedirectionFunctionVersion

Create an S3 Bucket and CloudFront Distribution

Our goals for serving a static site were for it to be secure, globally available and scalable. Now that all the prerequisites are done we can now create an Amazon S3 static site, which will be served by CloudFront and certainly achieve the goal of having a global and scalable architecture via cached edge points around the world.

But what about security? Well, we created a certificate to use with our CloudFront cached website, but we also don’t want to have the HTTP S3 Bucket website endpoint available to the world. To do this we use an Origin Access Identity which restricts access to S3 objects to only CloudFront and with a Route 53 Alias Record point to the CloudFront Distribution, the AWS architecture is complete.

# Deploy Command
export WEBSITE_ACM_CERTIFCATE_ARN=$(aws cloudformation describe-stacks --stack-name mysite-certificate --region us-east-1 --query 'Stacks[0].Outputs[?OutputKey==`Certificate`].OutputValue' --output text)

export REDIRECTION_FUNCTION_VERSION=$(aws cloudformation describe-stacks --stack-name mysite-redirect-index --region us-east-1 --query 'Stacks[0].Outputs[?OutputKey==`RedirectionFunctionVersion`].OutputValue' --output text)

aws cloudformation deploy \
  --template-file static-site.yaml \
  --stack-name mysite  \
  --parameter-overrides \
  DomainName=YOUR_SITE.com \
  AcmCertificateArn=$WEBSITE_ACM_CERTIFCATE_ARN \
  OriginRequestLambdaVersionArn=$REDIRECTION_FUNCTION_VERSION
# static-site.yaml
AWSTemplateFormatVersion: 2010-09-09

Description: CloudFront Static Site Template

Parameters:
  DomainName:
    Type: String
    Description: The DNS name of an existing Amazon Route 53 hosted zone e.g. somecloud.dev
    AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)

  AcmCertificateArn:
    Type: String
    Description: the Amazon Resource Name (ARN) of an AWS Certificate Manager (ACM) certificate.
    AllowedPattern: "arn:aws:acm:.*"

  OriginRequestLambdaVersionArn:
    Type: String
    Description: the Amazon Resource Name (ARN) of an AWS Lambda Function Version for CloudFront origin requests
    Default: ''

Conditions:
  OriginRequest: !Not [!Equals [!Ref OriginRequestLambdaVersionArn, '']]

Resources:
  S3Website:
    Type: AWS::S3::Bucket
    Properties:
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: index.html

  S3WebsitePolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Website
      PolicyDocument:
        Statement:
        - Action: s3:GetObject
          Effect: Allow
          Resource: !Join ['', ['arn:aws:s3:::', !Ref S3Website, /*]]
          Principal:
            CanonicalUser: !GetAtt S3WebsiteCloudFrontOriginAccessIdentity.S3CanonicalUserId

  S3WebsiteCloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Ref DomainName

  S3WebsiteCloudfront:
    Type: AWS::CloudFront::Distribution
    DependsOn:
      - S3Website
    Properties:
      DistributionConfig:
        Comment: Cloudfront Distribution pointing to S3 bucket
        Origins:
        - DomainName: !GetAtt S3Website.DomainName
          Id: S3Origin
          S3OriginConfig:
            OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${S3WebsiteCloudFrontOriginAccessIdentity}
        Enabled: true
        HttpVersion: http2
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          AllowedMethods:
          - GET
          - HEAD
          - OPTIONS
          CachedMethods:
          - GET
          - HEAD
          - OPTIONS
          Compress: true
          TargetOriginId: S3Origin
          ForwardedValues:
            QueryString: true
            Cookies:
              Forward: none
          ViewerProtocolPolicy: redirect-to-https
          LambdaFunctionAssociations: !If
          - OriginRequest
          - - EventType: origin-request
              LambdaFunctionARN: !Ref OriginRequestLambdaVersionArn
          - !Ref AWS::NoValue
        ViewerCertificate:
          AcmCertificateArn: !Ref AcmCertificateArn
          SslSupportMethod: sni-only
        Aliases:
          - !Ref DomainName
        PriceClass: PriceClass_All

  S3WebSiteRecordSet:
    Type: AWS::Route53::RecordSet
    Properties:
      AliasTarget:
        DNSName: !GetAtt S3WebsiteCloudfront.DomainName
        HostedZoneId: Z2FDTNDATAQYW2 #CloudFront Distribution
      HostedZoneName: !Sub ${DomainName}.
      Name: !Ref DomainName
      Type: A

Outputs:
  StackName:
    Description: Stack name.
    Value: !Sub ${AWS::StackName}

  Distribution:
    Description: CloudFront distribution id
    Value: !Ref S3WebsiteCloudfront
    Export:
      Name: !Sub ${AWS::StackName}-Distribution

  BucketName:
    Description: Name of the S3 bucket storing the static files.
    Value: !Ref S3Website
    Export:
      Name: !Sub ${AWS::StackName}-BucketName

  URL:
    Description: URL to static website.
    Value: !Sub https://${DomainName}/
    Export:
      Name: !Sub ${AWS::StackName}-URL

Create and Upload a Hugo Static Site to S3

Now that all your AWS resources are up, you can now create and deploy your static site to the Amazon S3 Bucket. There are so many options (including a good old fashion HTML page), but I recommend Hugo which is easy to install and get started. Once this is done you can use the command below to create and deploy your very first Hugo site.

export WEBSITE_BUCKET_NAME=$(aws cloudformation describe-stacks --stack-name mysite --query 'Stacks[0].Outputs[?OutputKey==`BucketName`].OutputValue' --output text)
hugo -s src
git clone https://github.com/rhazdon/hugo-theme-hello-friend-ng.git src/themes/hello-friend-ng
echo 'theme = "hello-friend-ng"' >> src/config.toml

aws s3 cp src/public s3://$WEBSITE_BUCKET_NAME --recursive