DEV Community

starpebble
starpebble

Posted on

Hosting The Hugo QuickStart Project on AWS Cloudfront with Lambda@Edge

The Hugo QuickStart is awfully simple. Why not host the quickstart project with serverless? Here is a guide to hosting Hugo content on serverless. The guide describes the steps to host the Hugo QuickStart project on Cloudfront with Lambda@Edge. It's really simplified. For fun, the code is nicknamed Flavor Cafe (Scotch). Go further than hugo deploy, which stops at S3.

Flavor Cafe (Scotch) - Hugo on Serverless

I've hosted the example quickstart site on serverless, visit the site at https://scotch.spicykey.com/. The site is securely served with https (http secure). Visit for fun.

Use the Cloudformation template in this post to try it. Any serverless computing enthusiast can use the template in his or her AWS account. The lambda@edge JavaScript function is teeny tiny and won't take up too much space in the cloud.

How it Works

Hosting the uri '/posts/my-first-post/'
The Hugo web server handles a gotcha - uri rewriting. That's why the quickstart project generated link '/posts/my-first-post/' is served with content from '/posts/my-first-post/index.html' by the Hugo server. '/posts/my-first-post/' is a sub directory distinguished by a trailing '/'. Cloudfront can also handle this sub directory gotcha. Cloudfront simply needs a little customization with Lambda@Edge. We can control Cloudfront's behavior where a Cloudfront distribution will accept rewritten uri's from a Lambda@Edge function. The lambda function will rewrite the uri's for sub directories. That means the quickstart project site will be easy to click around when hosted on Cloudfront. A web browser request for '/posts/my-first-post/' will be successful. The web browser will receive the content of the static file '/posts/my-first-post/index.html' hosted on S3.

Success
Flavor Cafe (Scotch) changes how Cloudfront behaves. This is a screenshot of the link '/posts/my-first-post/'

Alt Text

Failure
When Cloudfront receives a sub directory request such as '/posts/my-first-post/', it can fail. This is what failure looks like:

This XML file does not appear to have any style information associated with it. The document tree is shown below.
<Error>
<Code>AccessDenied</Code>
<Message>Access Denied</Message>
<RequestId>EA44DB04106A1902</RequestId>
<HostId>
ZslQyfgBnRJ/XPC2mVpNk8k/EBhCk+zE9Qa4zJ5pJIFwgeGKqekH0pF+gJoeQwrjPkD4uHGsFG4=
</HostId>
</Error>

Lambda

Flavor Cafe (Scotch) Lambda@Edge Code

'use strict';

// @starpebble on github
// hugo flavor cafe (scotch)

const DEFAULT_OBJECT = 'index.html';

exports.handler = (event, context, callback) => {
  const cfrequest = event.Records[0].cf.request;
  if (cfrequest.uri.length > 0 && cfrequest.uri.charAt(cfrequest.uri.length - 1) === '/') {
    // e.g. /posts/ to /posts/index.html
    cfrequest.uri += DEFAULT_OBJECT;
  }
  else if (!cfrequest.uri.match(/.(css|md|gif|ico|jpg|jpeg|js|png|txt|svg|woff|ttf|map|json|html)$/)) {
    // e.g. /posts to /posts/index.html
    cfrequest.uri += `/${DEFAULT_OBJECT}`;
  }
  callback(null, cfrequest);
  return true;
};     

JavaScript Code Comments

// Hugo on Cloudfront, Lambda@Edge function 
// Flavor Cafe (Scotch)
// @starpebble on github
//
// Two rewrite rules for hugo sub directory uri's.
// Example:
//   1. rewrite uri /posts/ to /posts/index.html
//   2. rewrite uri /posts  to /posts/index.html
//
// Add as many file extensions as you like for rule 2.
// uri's that end in a known file extensions are not rewritten by rule 2.

200 not 404
The Lambda@Edge function takes will rewrite the Hugo QuickStart project urls for directories to a default object, index.html. That's how Cloudfront serves the URI '/posts/my-first-post/' with content '/posts/my-first-posts/index.html' returning a perfect 200 instead of an ugly 404.

Trust me, don't try to host a Hugo site on Cloudfront without a little customization with Lambda! Here's how we can make it easy for user's to click around a Hugo site hosted on Cloudfront.

Cloudfront is an amazing machine. When content does fit perfectly into its rules, Cloudfront can be customized with lambda@edge. Each request for content is simply an event to a lambda function replicated to the edge, just like the static html content generated by Hugo. That means the lambda is replicated to points of presence all around the world, in proximity to users.

The Lambda@Edge nodejs function is very small. This is important. Smaller lambda functions are better, with size measured in bytes.

Steps

  1. Launch the Flavor Cafe (Scotch) Cloudformation template in this post, once
  2. Write down the Cloudfront domain url, one output of the template
  3. Create one quickstart project with Hugo guide
  4. Modify the config.toml baseURL key with the Cloudfront domain url and secure https protocol string, e.g. 'baseURL = "https://d123456789.cloudfront.net"'
  5. hugo -D
  6. Write down the S3 bucket name, one output of the template
  7. Upload the hugo generated content in the directory 'public/' to the S3 bucket, such as 'aws s3 sync'

Template

Cloudformation YAML

AWSTemplateFormatVersion: 2010-09-09
Description: 'Flavor Cafe (Scotch), host a static Hugo site on Cloudfront'
# one cloudfront distribution with one s3 bucket origin
# one lambda@edge function rewrites cloudfront sub directory requests to a root object
Parameters:
  flavor:
    Type: String
    Description: name of flavor
    Default: 'scotch'
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"
  LambdaAtEdgeFunction:
    DependsOn: 
      - LambdaEdgeFunctionRole
    Type: AWS::Lambda::Function
    Properties:
      Description: !Sub 'flavorcafe ${flavor} jamstack hugo lambda@edge'
      FunctionName: !Sub hugo-flavorcafe-${flavor}-function
      Role: !GetAtt LambdaEdgeFunctionRole.Arn
      Handler: index.handler
      Runtime: nodejs10.x
      Timeout: 1
      MemorySize: 128
      Code:
        ZipFile: >
          'use strict';

          // @starpebble on github
          // hugo flavor cafe (scotch)

          const DEFAULT_OBJECT = 'index.html';

          exports.handler = (event, context, callback) => {
            const cfrequest = event.Records[0].cf.request;
            if (cfrequest.uri.length > 0 && cfrequest.uri.charAt(cfrequest.uri.length - 1) === '/') {
              // e.g. /posts/ to /posts/index.html
              cfrequest.uri += DEFAULT_OBJECT;
            }
            else if (!cfrequest.uri.match(/.(css|md|gif|ico|jpg|jpeg|js|png|txt|svg|woff|ttf|map|json|html)$/)) {
              // e.g. /posts to /posts/index.html
              cfrequest.uri += `/${DEFAULT_OBJECT}`;
            }
            callback(null, cfrequest);
            return true;
          };          

  LambdaFunctionVersion:
    DependsOn:
      - LambdaAtEdgeFunction
    Type: AWS::Lambda::Version
    Properties: 
      Description: 'Lambda@Edge version'
      FunctionName: !Ref LambdaAtEdgeFunction
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub hugo-flavorcafe-${flavor}-${AWS::AccountId}
      BucketEncryption: 
        ServerSideEncryptionConfiguration: 
        - ServerSideEncryptionByDefault:
            SSEAlgorithm: AES256
      AccessControl: Private
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        IgnorePublicAcls: true
        BlockPublicPolicy: true
        RestrictPublicBuckets: true
      CorsConfiguration:
        CorsRules:
          -
            AllowedOrigins:
              - 'http*'
            AllowedMethods:
              - HEAD
              - GET
              - PUT
              - POST
              - DELETE
            AllowedHeaders:
              - '*'
            ExposedHeaders:
              - ETag
              - x-amz-meta-custom-header
  CFOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub FlavorCafe ${flavor} CloudFrontOAI
  S3BucketPolicy:
    DependsOn:
      - S3Bucket
      - CFOriginAccessIdentity
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Statement:
          -
            Effect: Allow
            Action: s3:GetObject
            Principal:
              CanonicalUser: !GetAtt CFOriginAccessIdentity.S3CanonicalUserId
            Resource: !Sub 'arn:aws:s3:::${S3Bucket}/*'  
  CFDistribution:
    DependsOn:
      - LambdaFunctionVersion
      - S3Bucket
      - CFOriginAccessIdentity
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: 'true'
        HttpVersion: 'http2'
        Comment: 'hugo flavor cafe distribution'
        DefaultRootObject: index.html
        Origins:
        - Id: S3OriginPrivateContent
          DomainName: !Sub hugo-flavorcafe-${flavor}-${AWS::AccountId}.s3.amazonaws.com
          S3OriginConfig:
            OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CFOriginAccessIdentity}
        DefaultCacheBehavior:
          TargetOriginId: S3OriginPrivateContent
          Compress: true
          ForwardedValues:
            QueryString: 'false'
            Headers:
              - Origin
            Cookies:
              Forward: none
          ViewerProtocolPolicy: redirect-to-https
          LambdaFunctionAssociations:
            - EventType: viewer-request
              LambdaFunctionARN: !Ref LambdaFunctionVersion
Outputs:
  BucketName:
    Description: The hugo content bucket name
    Value: !Ref S3Bucket
  CloudfrontDomain:
    Description: the cloudfront hosted dns domain name for the hugo static site
    Value: !GetAtt CFDistribution.DomainName

Diagrams

Serverless Resources
A picture is worth a thousand words. Here are two diagrams of the template above. The template can host a Hugo site like scotch.spicykey.com

Alt Text

Alt Text

Keep going

Just for fun
Hugo is fun. Go further than S3 and try Cloudfront. Hugo deploy is really different because it hosts the site on S3. Cloudfront is speedy, with http/2 and content hosted at the edge of the cloud. Have fun with the amazing content machine, Cloudfront!

starpebble
https://github.com/starpebble

Oldest comments (2)

Collapse
 
phlash profile image
Phil Ashby

Nice job! I should write up how I'm hosting hugo content on Azure storage, fronted by Azure CDN (so I can use my own domain name & certificate), look ma no servers! I have also got a custom rule in the CDN to set an X-Clacks-Overhead header :)

Collapse
 
starpebble profile image
starpebble

Exactly!