Hosting Hugo on AWS with S3 and CloudFront

Microsoft .NET Hugo Amazon Web Services Amazon CloudFront Amazon Simple Storage Service

Introduction

Hugo loves AWS

Hugo is a static site generator that is a great tool for creating blogs, documentation, and other types of websites. This site is also built with Hugo and hosted on AWS using S3 and CloudFront. It is a simple, fast, flexible way to host a static site. I didn’t find a good guide on how to host a Hugo site on AWS in their documentation, so I decided to write one.

In this article, we will learn how to build and deploy a Hugo site on newly created AWS S3 bucket and CloudFront distribution using AWS CDK.

Prerequisites

  • Hugo installed on your machine
  • AWS CDK already installed
  • .NET since we will use CDK in C#

Step 1: Create a new Hugo site

I assume that that you already have a Hugo site created. If not, let’s create a dummy one for this example:

hugo new site mysite
cd mysite

git init
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
echo "theme = 'ananke'" >> hugo.toml

hugo server

Follow the instructions on the Hugo website for more details

Step 2: Build the Hugo site

Build the Hugo site by running the following command:

cd mysite
hugo --minify --environment production

This will create a public folder with the static site files. This is the folder we will deploy to S3.

Step 3: Use AWS CDK to create infrastructure and deploy the site

I have created a simple CDK project in C# that will create an S3 bucket and CloudFront distribution together with all the necessary permissions, roles and configurations. Here is what we are going to create:

  • S3 bucket for the static site files
  • CloudFront distribution to serve the site
  • CloudFront Function on Viewer Request event to:
    • Rewrite URLs to support Hugo’s pretty URLs (e.g. /about/ instead of /about/index.html)
    • Redirect www to non-www URLs
    • Redirect non-trailing slash URLs to trailing slash URLs for consistency (e.g. /about to /about/)

Let’s take a look at the CDK code:

using Amazon.CDK;
using Amazon.CDK.AWS.CertificateManager;
using Amazon.CDK.AWS.CloudFront;
using Amazon.CDK.AWS.CloudFront.Origins;
using Amazon.CDK.AWS.IAM;
using Amazon.CDK.AWS.S3;
using Amazon.CDK.AWS.S3.Deployment;
using Constructs;
using System.Collections.Generic;
using static Amazon.CDK.AWS.CloudFront.CfnDistribution;
using static Amazon.CDK.AWS.CloudFront.CfnOriginAccessControl;

namespace Cdk
{
    public class HugoCdkStack : Stack
    {
        internal HugoCdkStack(Construct scope, string id, IStackProps props = null) : base(scope, id, props)
        {
            var bucketNameParam = new CfnParameter(this, "BucketName", new CfnParameterProps
            {
                Type = "String",
                Description = "The name of the bucket to host the HUGO website",
                Default = "fastfoodcoding-hugo"
            });

            // uncomment the following lines to enable custom domain and ACM certificate
            //var domainNameParam = new CfnParameter(this, "DomainName", new CfnParameterProps
            //{
            //    Type = "String",
            //    Description = "The domain name to use for the CloudFront distribution. Default is null",
            //});

            //var certificateArnParam = new CfnParameter(this, "CertificateArn", new CfnParameterProps
            //{
            //    Type = "String",
            //    Description = "The ARN of the ACM certificate to use for the CloudFront distribution. Default is null",
            //});

            var s3Bucket = new Bucket(this, "HugoSiteBucket", new BucketProps
            {
                BucketName = bucketNameParam.ValueAsString,
                RemovalPolicy = RemovalPolicy.DESTROY,
                BlockPublicAccess = BlockPublicAccess.BLOCK_ALL,
            });

            var cfnOriginAccessControl = new CfnOriginAccessControl(this, "OriginAccessControl", new CfnOriginAccessControlProps
            {
                OriginAccessControlConfig = new OriginAccessControlConfigProperty
                {
                    Name = "HugoSiteBucket-OriginAccessControl",
                    OriginAccessControlOriginType = "s3",
                    SigningBehavior = "always",
                    SigningProtocol = "sigv4"
                }
            });

            // Hugo uses pretty URLs instead of index.html for every path
            // S3 or CloudFront does not support this out of the box
            // Let's use a CloudFront lightweight function on Viewer Request event to achieve that
            // As a bonus, for consistency we can redirect www to non-www and fix trailing slashes in URLs
            var redirectFunction = new Function(this, "RedirectFunction", new FunctionProps
            {
                FunctionName = "HugoSiteViewerRequestFunction",
                Runtime = FunctionRuntime.JS_2_0,
                Comment = "Redirect to index.html if the request is for a directory",
                Code = FunctionCode.FromInline(GetFunctionCode()),
            });

            var cfnDistribution = new Distribution(this, "HugoSiteDistribution", new DistributionProps
            {
                DefaultRootObject = "index.html",
                DefaultBehavior = new BehaviorOptions
                {
                    Origin = new S3OacOrigin(s3Bucket, new S3OriginProps
                    {
                        OriginAccessIdentity = null,
                        ConnectionAttempts = 3,
                        ConnectionTimeout = Duration.Seconds(10)
                    }),
                    CachedMethods = CachedMethods.CACHE_GET_HEAD,
                    CachePolicy = CachePolicy.CACHING_OPTIMIZED,
                    ViewerProtocolPolicy = ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
                    FunctionAssociations = new[]
                    {
                        new FunctionAssociation
                        {
                            EventType = FunctionEventType.VIEWER_REQUEST,
                            Function = redirectFunction
                        }
                    }
                },
                // uncomment the following lines to enable custom domain and ACM certificate
                //DomainNames = bucketNameParam.ValueAsString,
                //Certificate = Certificate.FromCertificateArn(this, "Certificate", certificateArnParam.ValueAsString)
            });

            s3Bucket.AddToResourcePolicy(new PolicyStatement(new PolicyStatementProps
            {
                Actions = ["s3:GetObject"],
                Principals = [new ServicePrincipal("cloudfront.amazonaws.com")],
                Effect = Effect.ALLOW,
                Resources = [s3Bucket.ArnForObjects("*")],
                Conditions = new Dictionary<string, object>
                {
                    ["StringEquals"] = new Dictionary<string, object>
                    {
                        ["AWS:SourceArn"] = $"arn:aws:cloudfront::{this.Account}:distribution/{cfnDistribution.DistributionId}"
                    }
                }
            }));

            // workaround using the L1 construct to attach the OriginAccessControl to the CloudFront Distribution
            var l1CfnDistribution = cfnDistribution.Node.DefaultChild as CfnDistribution;
            l1CfnDistribution.AddPropertyOverride("DistributionConfig.Origins.0.OriginAccessControlId", cfnOriginAccessControl.AttrId);

            // sync our Hugo website public/ folder with the S3 bucket
            // don't forget to run "hugo" before deploying the CDK stack
            var _ = new BucketDeployment(this, "DeployWebSite", new BucketDeploymentProps
            {
                // sync the contents of the public/ folder with the S3 bucket
                Sources = [Source.Asset("../mysite/public")],
                DestinationBucket = s3Bucket,
                // invalidate the cache on the CloudFront distribution when the website is updated
                Distribution = cfnDistribution,
                DistributionPaths = ["/*"],
            });
        }

        private static string GetFunctionCode()
        {
            return @"
             async function handler(event) {
                const request = event.request;
                const host = request.headers.host.value;
                const uri = request.uri;

                if (!request.uri.endsWith('/') && !request.uri.includes('.')) {
                    request.uri += '/';
                    return redirectResponse('https://' + host + request.uri);
                }

                if (host.startsWith('www.')) {
                    return redirectResponse('https://' + host.replace('www.', '') + request.uri);
                }

                if (uri.endsWith('/')) {
                    request.uri += 'index.html';
                }

                return request;
            }

            function redirectResponse(newurl) {
                return {
                    statusCode: 301,
                    statusDescription: 'Moved Permanently',
                    headers: { 'location': { 'value': newurl } }
                };
            }";
        }

    }
}

public class S3OacOrigin : OriginBase
{
    public S3OacOrigin(IBucket bucket, IOriginProps props = null) : base(bucket.BucketRegionalDomainName, props)
    {
    }

    // workaround to avoid the "OriginAccessIdentity" property to be rendered in the CloudFormation template
    protected override IS3OriginConfigProperty RenderS3OriginConfig()
    {
        return new S3OriginConfigProperty
        {
            OriginAccessIdentity = ""
        };
    }
}

Step 4: Deploy the CDK stack

To deploy the CDK stack, simply run the following commands:

cdk deploy --parameters BucketName=fastfoodcoding-hugo

Conclusion

That’s it! You have successfully deployed a Hugo site on AWS using S3 and CloudFront. You can now access your site using the CloudFront distribution URL. You can also use a custom domain and ACM certificate by uncommenting the respective lines in the CDK code. But this requires some additional setup in Route 53 and ACM.