Building a static blog site on AWS - Part 2

April 21, 2025    AWS Hugo Blog

This is a continuation of my previous blog post, which I covered the high level architecture design and walked through the process of building the AWS infrastructure supporting my static blog site.

In this post, I will cover the development process of building my blog site and take a closer look at some customisations of my blog site architecture.

Building the blog site

Hugo is the static site generator choice to build my blog site because of its amazing speed and flexibility. It is easy to build with Hugo - I just need to install Hugo, pick a nice theme, and start to write my content.

I run Hugo as a Docker container for my blog development work. I am using the Hugo Docker image klakegg/hugo, which is unfortunately no longer being maintained. There are other community Hugo Docker images available.

Get started with Hugo by following these easy steps.

Install and configure Hugo

Run the docker command to install the Docker image.

docker pull klakegg/hugo

Create the Hugo directory structure for your project, eg. blog-wkhoo, and change the current directory to the root of your project.

docker run --rm -it -v $(pwd):/src klakegg/hugo new site blog-wkhoo
cd blog-wkhoo

Then, initialise an empty Git repository at the root of your project, and clone a theme into the themes directory, eg. hugo-sustain.

git init
git submodule add https://github.com/nurlansu/hugo-sustain.git themes/hugo-sustain

You can choose from a abundant of Hugo themes. Add the theme name to the Hugo site configuration file config.toml.

echo "theme = 'hugo-sustain'" >> config.toml

This command will start the Hugo server and build the blog site with the chosen theme.

docker run --rm -p 1313:1313 -it -v $(pwd):/src klakegg/hugo server

The command output should look something like this:

Start building sites …
hugo v0.111.3-5d4eb5154e1fed125ca8e9b5a0315c4180dab192 linux/arm64 BuildDate=2023-03-12T11:40:50Z VendorInfo=hugoguru

                   | EN
-------------------+-----
  Pages            |  7
  Paginator pages  |  0
  Non-page files   |  0
  Static files     |  0
  Processed images |  0
  Aliases          |  0
  Sitemaps         |  1
  Cleaned          |  0

Built in 9 ms
Watching for changes in /src/{archetypes,assets,content,data,layouts,static,themes}
Watching for config changes in /src/config.toml
Environment: "DEV"
Serving pages from memory
Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender
Web Server is available at http://localhost:1313/ (bind address 0.0.0.0)
Press Ctrl+C to stop

If you type the URL http://localhost:1313 on your web browser, you will see the Hugo site main page.

Hugo initial page

Not much to see so let’s do some Hugo customisation and write a post.

Customise Hugo site configuration

The default Hugo site configuration file is config.toml at the root directory of your project.

baseURL = 'http://example.org/'
languageCode = 'en-us'
title = 'My New Hugo Site'
theme = 'hugo-sustain'

You can customise the Hugo site configuration, for example to customise social icons and define entries for a main menu.

To customise the main Hugo page, create this page content/_index.md and write the page content.

---
title: Home
---
Hello, I'm learning Hugo.

Then create a sub-folder static/images.

mkdir static/images

You can set a profile image on the main page by copying an image file to static/images/profile.png.

Add the following parameters to the site configuration file config.toml at the root of your project.

[params]
  avatar = "/images/profile.png"
  author = "Bob Jones"
  description = "My personal blog site"

[params.social]
  Github        = "username"
  Email         = "[email protected]"
  Twitter       = "username"
  LinkedIn      = "username"
  Stackoverflow = "username"
  Medium        = "username"
  Telegram      = "username"
  RSS           = "/posts/index.xml"

## Main Menu
[[menu.main]]
  name = "posts"
  weight = 100
  identifier = "posts"
  url = "/posts/"

The configuration parameters customise the main page avatar, set up the social icons and create a main menu entry posts.

Hugo will detect the site configuration changes and automatically rebuild the pages. Refresh the page on your web browser to see the modified page.

Hugo profile page

Write your first post

Let’s write a post. Run this command to add a new page to the blog site.

docker run --rm -it -v $(pwd):/src klakegg/hugo new content/posts/hello-world.md

Hugo will create the file hello-world.md in the directory content/posts. Open the file content/posts/hello-world.md with a text editor. You will find that Hugo creates the file with a front matter.

---
title: "Hello World"
date: 2025-04-18T03:12:13Z
draft: true
---

A front matter is used to add metadata to your content. By default, the draft value is set to true and Hugo does not publish the draft content when you build the site.

You can however get Hugo to include draft content by specifying the command parameter --buildDrafts or -D so that you can preview the page in the web browser.

docker run --rm -p 1313:1313 -it -v $(pwd):/src klakegg/hugo server -D

Once you are done writing your content and ready to publish, change the metadata “draft: true” to “draft: false”.

---
title: "Hello World"
date: 2025-04-18T03:12:13Z
draft: false
---
## Introduction

This is my **first** post.

To see your post, navigate to the menu POSTS to get to the posts archive.

Hugo posts archive

Click on the title Hello World to view your post.

Hugo first post

When you publish the site, Hugo generates the complete static site in the public directory at the root of your project. This includes HTML files, images, CSS, and JavaScript assets. The contents of the public directory are then copied to the S3 bucket that hosts your static website.

Publish your blog

You can now push the blog content to the CodeCommit repository blog-wkhoo, which was created using Terraform as part of the blog site infrastructure setup.

The blog site publishing process is automated using CodePipeline, which is triggered whenever the main branch is updated in CodeCommit. CodePipeline initiates a CodeBuild project to build the Hugo artifacts and deploy them to the S3 bucket.

Add the CodeCommit repository URL to your local git configuration, setting it as origin in your local repository.

git remote add origin https://git-codecommit.ap-southeast-2.amazonaws.com/v1/repos/blog-wkhoo

Once the remote repository URL has been mapped, you can push the local main branch to CodeCommit.

$ git add .
$ git commit -m "first commit"
[main (root-commit) 514f466] first commit
 8 files changed, 48 insertions(+)
 create mode 100644 .gitmodules
 create mode 100644 .hugo_build.lock
 create mode 100644 archetypes/default.md
 create mode 100644 config.toml
 create mode 100644 content/_index.md
 create mode 100644 content/posts/hello-world.md
 create mode 100644 static/images/profile.png
 create mode 160000 themes/hugo-sustain

$ git push -u origin main
Enumerating objects: 15, done.
Counting objects: 100% (15/15), done.
Delta compression using up to 8 threads
Compressing objects: 100% (8/8), done.
Writing objects: 100% (15/15), 123.64 KiB | 41.21 MiB/s, done.
Total 15 (delta 0), reused 0 (delta 0), pack-reused 0
remote: Validating objects: 100%
To https://git-codecommit.ap-southeast-2.amazonaws.com/v1/repos/blog-wkhoo
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

The initial push to remote main branch will automatically trigger CodePipeline to build and deploy your Hugo site to Amazon S3.

You should be able to get to the static blog site by using your public domain name if you have redirected it to the CloudFront distribution domain name (*.cloudfront.net) with a CNAME record on the domain registrar DNS.

Congratulations! You have a working CI/CD pipeline now where you can start writing blog posts.

Customising Hugo URLs

Hugo by default uses pretty URLs which simply work in most hosting environments. However when using CloudFront with S3 origin, it does not automatically translate the pretty URLs.

The issue arises because S3 is an object storage service that lacks native support for complex routing or URL rewriting. It treats each file as a separate entity and does not automatically resolve URLs without file extension.

CloudFront caches responses from S3 to improve performance. However it expects direct mappings to specific files and does not handle pretty URLs correctly. Without proper configuration, CloudFront cannot resolve requests for directory style (pretty) URLs therefore leading to missing pages.

The workaround is to deploy CloudFront Functions to rewrite Hugo pretty URLs when CloudFront receives a (viewer) request.

resource "aws_cloudfront_function" "s3_blogsite" {
  code                         = file("${path.module}/functions/hugo-rewrite-pretty-urls.js")
  comment                      = "Enable pretty URLs for Hugo"
  name                         = "hugo-rewrite-pretty-urls"
  runtime                      = "cloudfront-js-2.0"
}

Here is the CloudFront function JavaScript code that I use:

function handler(event) {
  let request = event.request;
  let uri = request.uri;

  // Redirect URLs ending in .html (except index.html) to their directory equivalent
  if (uri.endsWith('.html') && !uri.endsWith('/index.html')) {
      return {
          statusCode: 301,
          statusDescription: 'Moved Permanently',
          headers: {
              "location": { "value": uri.replace(/\.html$/, "/") }
          }
      };
  }

  // Append index.html if the request URI ends with a slash
  if (uri.endsWith('/')) {
      request.uri += 'index.html';
  } 
  // Append /index.html if there's no file extension
  else if (!uri.includes('.')) {
      request.uri += '/index.html';
  }

  return request;
}

The CloudFront function appends index.html to the viewer requests if the request URL does not include a file name and/or extension. The function intercepts the request and modifies it before passing it to the origin S3 bucket. When a blog reader browses to /posts/hello-world or /posts/hello-world/, the function will append /index.html or index.html to the request.

When a blog reader accesses a URL like /posts/hello-world.html, they will be redirected to the prettified URL /posts/hello-world/. If they access /posts/hello-world/index.html, the request will proceed without redirection and pass through to the origin S3 bucket.

Why use CloudFront Functions instead of Lambda@Edge

CloudFront Functions are ultra-lightweight JavaScript functions that execute at the CloudFront edge. They are designed for high performance, with sub-millisecond latency, and are very cost-effective especially for high-traffic static sites.

Pros:

  • Low cost ($0.10 per 1 million invocations)
  • Ultra-low latency
  • Instant deployment globally
  • No AWS Lambda limits (no Lambda cold starts)

Cons:

  • Limited functionality (no access to the body of the request/response)
  • Only supports viewer request/response events (not origin request/response)
  • Only supports JavaScript

For a static blog site, you only need lightweight request manipulation to rewrite URLs. This is perfect use case for CloudFront Functions. The other reason to use CloudFront Functions is that you get 2 million CloudFront Functions invocations for free each month with AWS Free Tier.

Lambda@Edge lets you run full AWS Lambda functions at CloudFront edge locations, giving you much more power and flexibility.

Pros:

  • Full Node.js or Python runtime
  • Can access request and response bodies
  • Supports all CloudFront trigger points (viewer and origin events)
  • Integrates with other AWS services

Cons:

  • Higher cost ($0.60 per 1 million invocations)
  • Slower startup time (Lambda cold starts can affect latency)
  • More complex to deploy and maintain

Unless you are doing something complex like serving dynamic content, handling authentication, or interacting with other AWS services, Lambda@Edge is probably overkill and over budget.

Securing CloudFront S3 origin

It is important to ensure your S3 bucket is privately accessible when serving static content from Amazon S3 using CloudFront. CloudFront provides two options to secure your S3 origin using authenticated requests - Origin Access Identity (OAI) and Origin Access Control (OAC).

OAI has been around for a while. It creates a virtual identity principal that CloudFront uses to fetch content from your private S3 bucket. When you enabled OAI for CloudFront, optionally the S3 bucket policy is updated to allow access only to that identity.

OAI is considered legacy. OAC is the modern and more secure alternative to OAI. AWS recommends using OAC for new CloudFront distributions because it supports1:

  • Supports all Amazon S3 buckets in all AWS Regions, including opt-in Regions launched after December 2022
  • Amazon S3 server-side encryption with AWS KMS (SSE-KMS)
  • Dynamic requests (PUT and DELETE) to Amazon S3

OAC is the way to go as it aligns better with AWS security best practices, and gives you tighter control over who can access your content.

CloudFront cache invalidation

CloudFront lets you invalidate cached files at the edge locations so that your blog site serves updated content when you published new posts or made changes to your site. Your AWS account gets 1,000 invalidation paths for free each month across all CloudFront distributions. After that, AWS charges per additional path.

You can invalidate a single file, eg., /images/logo.jpg, or use a wildcard, eg., /images/*, to target multiple files at once. Even if a wildcard matches hundreds of files, it still counts as one path.

A few key things to remember:

  • Charges are based on the number of paths, not how many files are actually invalidated.
  • A request that includes multiple paths will count each path separately for billing.

For my static blog, I use “/*” to invalidate the top-level files therefore keeping the costs low and to stay within that monthly free tier. In my CodeBuild, I added the CloudFront CLI command to invalidate the CloudFront cache in the post_build phase prior to the CodePipeline deploy to S3 stage.

  post_build:
    commands:
      - echo In post_build phase...
      - echo CloudFront invalidation
      - aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/*"

This post_build phase is not necessary if I use Hugo’s built-in deploy function to deploy my site to Amazon S3. The Hugo deploy command invalidates the CloudFront cache by default.

Wrap up

In this post, I walked you through the process of building a static website using Hugo and took a deeper dive into the architecture behind my blog site setup on AWS. Stay tuned for my next post.