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/'
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
- Launch the Flavor Cafe (Scotch) Cloudformation template in this post, once
- Write down the Cloudfront domain url, one output of the template
- Create one quickstart project with Hugo guide
- Modify the config.toml baseURL key with the Cloudfront domain url and secure https protocol string, e.g. 'baseURL = "https://d123456789.cloudfront.net"'
- hugo -D
- Write down the S3 bucket name, one output of the template
- 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
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
Top comments (2)
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 :)
Exactly!