Building a static blog site on AWS - Part 1
February 23, 2025 AWS Hugo Blog
Time flies - summer is almost over in New Zealand. Even though it has only been three weeks, my six-week summer break is already beginning to feel like a distant memory.
About nine months ago, I started this blog and decided to host my blog site on Amazon S3 and utilise Amazon CloudFront for the content delivery. My initial capital investment was simply 1 US dollar for the domain name wkhoo.com
registration.
For nine months, my monthly AWS bills were either $0 or $0.01 (for S3 storage cost although my usage is well under the free tier limit; probably due to decimals round up but weird), all because I am reaping the benefits of AWS 12 months Free Tier program and have been staying within the limits so far.
I want to dive into the architecture behind my static blog site blog.wkhoo.com
and share how I built it from scratch using Terraform. So if you want to know how to build a blog site costing only 1 US dollar, then read on. 😄
My architecture
This architecture diagram illustrates the AWS services powering my blog site.
The key services involved are:
- Amazon S3: Acts as storage and hosts the static website.
- Amazon CloudFront: Delivers content using the S3 bucket as the origin.
- AWS CodePipeline: Automates the build and deployment process for the static website whenever changes are made.
- AWS CodeBuild: Builds the static website and generates deployment-ready artefacts.
- AWS CodeCommit: Serves as the source control repository for the blog site content.
- Amazon Certificate Manager: Provides a public certificate for the custom domain name used with CloudFront.
- Amazon EventBridge: Configures an event rule to trigger CodePipeline.
- AWS Budgets: Sends notifications when AWS spending exceeds predefined thresholds.
All services are serverless. I opted for this serverless architecture so that there are no servers to manage. It is secure, scalable, and cost-effective way to run my blog site as I only pay for what I use.
In a nutshell, the static blog site is deployed and hosted in a private S3 bucket. A CloudFront distribution is set up with the S3 bucket as an origin. The CloudFront distribution fetches and caches the content from S3 bucket and delivers the pages to the blog readers.
Then, I created a CNAME record on my domain registrar DNS that points to the CloudFront distribution domain name. When someone access my blog domain blog.wkhoo.com
, the request is routed to CloudFront, which delivers the content from the cache or fetches from S3.
When a new blog post is pushed to CodeCommit and merged into the main branch, it will trigger CodePipeline. CodePipeline will then initiate CodeBuild to generate the Hugo files and artefacts, and then deploy the artefacts to the S3 bucket.
Prerequisites
1. A public domain name registration
You need to have a public domain name. You can register a new domain name with Amazon Route 53 but I bought my domain name from
IONOS
for their US$1 .com
domain one year deal.
I might look at transferring my domain name to Route 53 so that I can manage it with Terraform as one stack. It costs US$14, as of this post writing, to register or transfer .com domain with Route 53.
2. A static site generator
Hugo is one of the most popular open-source static site generators. With its amazing speed and flexibility, Hugo makes it easy to build a blog site. I decided to use Hugo because it is fast and easy to install. Refer to the Hugo Quick start documentation to get started.
The main advantage for me is that I can just focus on writing content in Markdown without having to mess around with server hosting. Hugo has abundant of themes that I can choose. I just need to install Hugo, pick a nice theme, and start to write my content.
3. A new AWS account for static site hosting
You can simply create a new AWS account and be eligible for the AWS Free Tier program for 12 months.
Here is the summary of key AWS services covered by AWS Free Tier program:
AWS Service | Entitlement | Tier type |
---|---|---|
Amazon S3 | 5GB of standard storage; 20K Get requests per month; 2K Put requests month | 12 months free |
Amazon CloudFront | 1TB of data transfer out; 10M HTTP or HTTPS requests per month; 2M CloudFront Function per month | Always free |
AWS CodeBuild | 100 build minutes (build.general1.small compute type usage) | Always free |
AWS CodeCommit | 5 active users per month; 50 GB-month storage per month; 10K Git requests per month | Always free |
AWS CodePipeline | 1 active pipeline per month | Always free |
Amazon Certificate Manager | Public SSL/TLS certificates provisioned through ACM are free | Always free |
Amazon EventBridge | All state change events published by AWS services by default are free; 14 million scheduled free invocations per month | Always free |
AWS Budgets | 62 Budget-days for free per month | Always free |
But be warned if you exceed the free usage limits on any of the services, you will be charged for the overage.
Building the blog infrastructure
Now that I have covered the architecture at a high level, let’s walk through the process of building the AWS infrastructure. I will break down the Terraform code into snippets so it is easy to follow.
Before we get started, make sure you have an AWS account. If you don’t, please create one. You need to set up an IAM user with AdministratorAccess permissions and create IAM user access keys to authenticate to AWS. Please remember to enable MFA for additional security.
You will need to have Terraform installed on your machine. Refer HashiCorp Terraform installation guide to install the Terraform CLI binary.
Set up the S3 bucket for hosting
Amazon S3 is a cost-effective, scalable, and simple solution for hosting a static blog. You only pay for storage and data transfer, making it an affordable option even for blogs with fluctuating traffic.
With 99.99% availability and 11 nines of durability, S3 ensures my blog contents are always accessible and protected against data loss. It scales automatically to handle traffic spikes without manual intervention. S3 also integrates seamlessly with Amazon CloudFront, which speeds up content delivery by caching files at global edge locations.
For my setup, two S3 buckets are required to store Hugo blog contents and build artefacts. S3 bucket names are globally unique therefore I created the S3 bucket name to match my blog site domain name so no one else can have the same bucket name.
s3.tf
# Blogsite bucket
resource "aws_s3_bucket" "blogsite" {
bucket = var.domain_name
tags = var.tags
}
resource "aws_s3_bucket_website_configuration" "blogsite" {
bucket = aws_s3_bucket.blogsite.id
index_document {
suffix = "index.html"
}
error_document {
key = "404.html"
}
}
resource "aws_s3_bucket_policy" "blogsite" {
bucket = aws_s3_bucket.blogsite.id
policy = data.aws_iam_policy_document.s3_bucket_policy.json
}
# Hugo artefacts bucket
resource "aws_s3_bucket" "artefact" {
bucket = var.artefact_bucket
# delete non-empty bucket
force_destroy = true
tags = var.tags
}
resource "aws_s3_bucket_lifecycle_configuration" "artefact" {
bucket = aws_s3_bucket.artefact.id
# delete objects and non-current versions after 30 days
rule {
id = "expire-after-30days"
status = "Enabled"
expiration {
date = null
days = 30
expired_object_delete_marker = false
}
noncurrent_version_expiration {
newer_noncurrent_versions = null
noncurrent_days = 30
}
}
}
The blog site S3 bucket is made private and restrict public access because I only want CloudFront to have direct access to the bucket. I need an S3 bucket policy that allows CloudFront to retrieve the objects.
data "aws_iam_policy_document" "s3_bucket_policy" {
statement {
sid = "AllowCloudFrontServicePrincipal"
actions = [
"s3:GetObject"
]
resources = [
"${aws_s3_bucket.blogsite.arn}/*"
]
principals {
type = "Service"
identifiers = [
"cloudfront.amazonaws.com"
]
}
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [aws_cloudfront_distribution.s3_blogsite.arn]
}
}
}
This bucket policy ensures that only the CloudFront distribution can access the blog contents on the S3 bucket. CloudFront Origin Access Control (OAC) will be used to allow CloudFront to securely fetch files from S3 origin.
Configure CloudFront distribution
Amazon CloudFront enhances the blog site performance by caching content at AWS global edge locations, reducing latency and speeding up load times. CloudFront also improves security with built-in DDoS protection using AWS Shield Standard.
By serving static assets like HTML, CSS, and images from the nearest edge location, it minimises requests to the S3 bucket, lowering cost and handling traffic spikes efficiently.
This setup provides a fast, scalable, and cost-effective solution for hosting my blog site.
cloudfront.tf
resource "aws_cloudfront_distribution" "s3_blogsite" {
origin {
domain_name = aws_s3_bucket.blogsite.bucket_regional_domain_name
origin_id = "s3-${var.domain_name}"
origin_access_control_id = aws_cloudfront_origin_access_control.s3_blogsite.id
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
aliases = local.domain_name
default_cache_behavior {
allowed_methods = [
"GET",
"HEAD",
]
cached_methods = [
"GET",
"HEAD",
]
target_origin_id = "s3-${var.domain_name}"
# Managed cache policy
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
viewer_protocol_policy = "redirect-to-https"
min_ttl = var.cloudfront_min_ttl
default_ttl = var.cloudfront_default_ttl
max_ttl = var.cloudfront_max_ttl
# Associate CloudFront function to CloudFront distribution
function_association {
event_type = "viewer-request"
function_arn = "arn:aws:cloudfront::211125687259:function/hugo-rewrite-pretty-urls"
}
}
price_class = var.price_class
restrictions {
geo_restriction {
restriction_type = var.cloudfront_geo_restriction_type
locations = []
}
}
# Specify a default SSL certificate if use_default_domain is true
dynamic "viewer_certificate" {
for_each = local.default_certs
content {
cloudfront_default_certificate = true
}
}
# Specify a custom SSL certificate from ACM if use_default_domain is false
dynamic "viewer_certificate" {
for_each = local.acm_certs
content {
acm_certificate_arn = aws_acm_certificate.domain_name[0].arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1"
}
}
custom_error_response {
error_code = 403
response_code = 200
error_caching_min_ttl = 0
response_page_path = "/index.html"
}
wait_for_deployment = false
tags = var.tags
depends_on = [
aws_s3_bucket.blogsite
]
}
Using Origin Access Control , it ensures all requests pass through CloudFront, keeping the S3 bucket secure.
resource "aws_cloudfront_origin_access_control" "s3_blogsite" {
name = "oac-${var.domain_name}"
description = "OAC for ${var.domain_name} S3 blog site"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
CloudFront uses HTTPS for all communications and provides a default CloudFront domain certificate. I decided to use a custom CloudFront domain name blog.wkhoo.com
for my blog site access.
With AWS Certificate Manager, you can provision public SSL/TLS certificates for free. 👍
Note that to use the ACM certificate with CloudFront, the certificate must be issued on the
us-east-1
region.
resource "aws_acm_certificate" "domain_name" {
# CloudFront uses certificates from us-east-1 region only
provider = aws.cloudfront
count = var.use_default_domain ? 0 : 1
domain_name = coalesce(var.acm_certificate_domain, "*.${var.hosted_zone}")
validation_method = "DNS"
tags = var.tags
lifecycle {
create_before_destroy = true
}
}
Create IAM roles for pipeline execution
To ensure secure and efficient execution of the CI/CD pipeline, I need to create IAM roles with the appropriate permissions for AWS CodePipeline, CodeBuild, and CloudWatch Events. Three IAM roles are required for the CI/CD pipeline execution.
iam.tf
CodeBuild requires permissions to pull source code from CodeCommit, fetch dependencies, store build artefacts in S3, publish logs to CloudWatch, and invalidate CloudFront distribution cache.
resource "aws_iam_role" "codebuild_service_role" {
name = "CodeBuildServiceRole"
assume_role_policy = data.aws_iam_policy_document.codebuild_trust_policy.json
}
resource "aws_iam_role_policy" "codebuild_service_policy" {
name = "CodeBuildServicePolicy"
role = aws_iam_role.codebuild_service_role.id
policy = data.aws_iam_policy_document.codebuild_service_policy.json
}
data "aws_iam_policy_document" "codebuild_service_policy" {
statement {
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = [
"arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/codebuild/*",
"arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/codebuild/*:*"
]
}
statement {
actions = [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetBucketVersioning",
"s3:PutObject"
]
resources = [
aws_s3_bucket.blogsite.arn,
"${aws_s3_bucket.blogsite.arn}/*",
aws_s3_bucket.artefact.arn,
"${aws_s3_bucket.artefact.arn}/*"
]
}
statement {
actions = [
"codebuild:CreateReportGroup",
"codebuild:CreateReport",
"codebuild:UpdateReport",
"codebuild:BatchPutTestCases"
]
resources = ["arn:aws:codebuild:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:report-group/*"]
}
statement {
actions = [
"cloudfront:CreateInvalidation"
]
resources = [aws_cloudfront_distribution.s3_blogsite.arn]
}
}
data "aws_iam_policy_document" "codebuild_trust_policy" {
statement {
actions = [
"sts:AssumeRole"
]
principals {
type = "Service"
identifiers = [
"codebuild.amazonaws.com"
]
}
}
}
CodePipeline needs permissions to access CodeCommit, CodeBuild, and S3 to orchestrate the deployment process, ie. to pull source, retrieve artefacts, and trigger builds.
resource "aws_iam_role" "codepipeline_service_role" {
name = "CodePipelineServiceRole"
assume_role_policy = data.aws_iam_policy_document.codepipeline_trust_policy.json
}
resource "aws_iam_role_policy" "codepipeline_service_role_policy" {
name = "CodePipelineServiceRolePolicy"
role = aws_iam_role.codepipeline_service_role.id
policy = data.aws_iam_policy_document.codepipeline_service_policy.json
}
data "aws_iam_policy_document" "codepipeline_service_policy" {
statement {
actions = [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetBucketVersioning",
"s3:PutObject"
]
resources = [
aws_s3_bucket.blogsite.arn,
"${aws_s3_bucket.blogsite.arn}/*",
aws_s3_bucket.artefact.arn,
"${aws_s3_bucket.artefact.arn}/*"
]
}
statement {
actions = ["iam:PassRole"]
resources = [aws_iam_role.codebuild_service_role.arn]
}
statement {
actions = [
"codebuild:BatchGetBuilds",
"codebuild:StartBuild"
]
resources = [aws_codebuild_project.hugos3blog.arn]
}
statement {
actions = [
"codecommit:CancelUploadArchive",
"codecommit:GetBranch",
"codecommit:GetCommit",
"codecommit:GetUploadArchiveStatus",
"codecommit:UploadArchive"
]
resources = [aws_codecommit_repository.hugos3blog.arn]
}
}
data "aws_iam_policy_document" "codepipeline_trust_policy" {
statement {
actions = [
"sts:AssumeRole"
]
principals {
type = "Service"
identifiers = [
"codepipeline.amazonaws.com"
]
}
}
}
CloudWatch Events is used to trigger deployments when new changes are pushed to CodeCommit and merged to the main branch. I need a role with permissions to invoke CodePipeline.
resource "aws_iam_role" "cwe_pipeline_role" {
name = "AmazonCloudWatchEventRole"
assume_role_policy = data.aws_iam_policy_document.cwe_trust_policy.json
}
resource "aws_iam_role_policy" "cwe_pipeline_execution" {
name = "cwe-pipeline-execution"
role = aws_iam_role.cwe_pipeline_role.id
policy = data.aws_iam_policy_document.cwe_pipeline_execution.json
}
data "aws_iam_policy_document" "cwe_pipeline_execution" {
statement {
actions = [
"codepipeline:StartPipelineExecution"
]
resources = [aws_codepipeline.hugos3blog.arn]
}
}
data "aws_iam_policy_document" "cwe_trust_policy" {
statement {
actions = [
"sts:AssumeRole"
]
principals {
type = "Service"
identifiers = [
"events.amazonaws.com"
]
}
}
}
Set up a CI/CD pipeline
The CI/CD pipeline was an afterthought. After provisioning the infrastructure and getting the blog site up and running, I realised that I need to automate the Hugo build and deploy process. I quickly set up a basic pipeline using AWS CodePipeline. While the pipeline has room for improvement and optimisation, it serves its purpose as a starting point.
codepipeline.tf
I use CodeCommit as source repository to store the blog contents and Hugo files.
resource "aws_codecommit_repository" "hugos3blog" {
repository_name = var.repo_name
description = "CodeCommit repository for Hugo files and blog posts"
}
Note that effective 25 July 2024, AWS CodeCommit is no longer available to new customers. Learn more.
CodeBuild simply executes the Hugo installation, downloads the Hugo theme, builds the Hugo site, and invalidates the CloudFront distribution cache prior to the deployment to S3. The Hugo version and theme download URLs are hardcoded, which is not ideal, and I should include more validation checks to enhance the build process.
resource "aws_codebuild_project" "hugos3blog" {
name = "HugoS3Blog"
description = "Submit build jobs for ${var.repo_name} as part of CI/CD pipeline"
service_role = aws_iam_role.codebuild_service_role.arn
artifacts {
type = "CODEPIPELINE"
packaging = "NONE"
encryption_disabled = false
}
environment {
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/standard:7.0"
type = "LINUX_CONTAINER"
environment_variable {
name = "CLOUDFRONT_DISTRIBUTION_ID"
value = aws_cloudfront_distribution.s3_blogsite.id
}
}
source {
type = "CODEPIPELINE"
buildspec = <<EOF
version: 0.2
phases:
install:
runtime-versions:
python: 3.10
commands:
- echo In install phase...
- apt-get update
- echo Installing hugo
- curl -L -o hugo.deb https://github.com/gohugoio/hugo/releases/download/v0.111.3/hugo_0.111.3_linux-amd64.deb
- dpkg -i hugo.deb
pre_build:
commands:
- echo In pre_build phase...
- echo Clone hugo-sustain theme
- git clone https://github.com/nurlansu/hugo-sustain.git themes/hugo-sustain
- echo Current directory is $CODEBUILD_SRC_DIR
- ls -la
build:
commands:
- echo In build phase...
- echo Build Hugo site
- hugo
post_build:
commands:
- echo In post_build phase...
- echo CloudFront invalidation
- aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/*"
artifacts:
files:
- '**/*'
base-directory: public
EOF
}
logs_config {
cloudwatch_logs {
status = "ENABLED"
}
}
badge_enabled = false
}
CodePipeline automates the CI/CD workflow, making it easier to deploy blog updates efficiently. It integrates seamlessly with CodeCommit and CodeBuild, and automatically deploys updates to the S3 bucket. This eliminates manual steps, leading to fewer errors.
resource "aws_codepipeline" "hugos3blog" {
name = "HugoS3Blog"
role_arn = aws_iam_role.codepipeline_service_role.arn
artifact_store {
location = aws_s3_bucket.artefact.bucket
type = "S3"
}
stage {
name = "Source"
action {
name = "CodeCommit"
category = "Source"
owner = "AWS"
provider = "CodeCommit"
version = "1"
output_artifacts = ["SourceArtefact"]
configuration = {
BranchName = "main"
PollForSourceChanges = false
RepositoryName = aws_codecommit_repository.hugos3blog.repository_name
}
run_order = 1
}
}
stage {
name = "Build"
action {
name = "CodeBuild"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
version = "1"
input_artifacts = ["SourceArtefact"]
output_artifacts = ["BuildArtefact"]
configuration = {
ProjectName = aws_codebuild_project.hugos3blog.name
}
run_order = 1
}
}
stage {
name = "Deploy"
action {
name = "Deploy-to-S3"
category = "Deploy"
owner = "AWS"
provider = "S3"
version = "1"
input_artifacts = ["BuildArtefact"]
configuration = {
BucketName = aws_s3_bucket.blogsite.id
Extract = "true"
}
run_order = 1
}
}
}
I chose to use CodePipeline
Amazon S3 deploy action
to deploy the Hugo artefact (output from CodeBuild) to the S3 bucket instead of the Hugo’s built-in deploy
function. Using the built-in deploy feature would require storing long-term credentials (IAM user access keys) in a secret vault for retrieval during the build, which I want to avoid.
Configure automatic pipeline trigger
I want my CI/CD pipeline to trigger only when changes are made to the main
branch so I created an Amazon CloudWatch event rule to detect the CodeCommit repository state change and trigger AWS CodePipeline execution whenever a commit is pushed to the main
branch.
cloudwatch.tf
resource "aws_cloudwatch_event_rule" "codecommit_rule" {
name = "hugo-codecommit-repo-update"
description = "Triggered by CodeCommit events to main branch"
event_pattern = jsonencode({
source = ["aws.codecommit"]
detail-type = ["CodeCommit Repository State Change"]
resources = [aws_codecommit_repository.hugos3blog.arn]
detail = {
event = ["referenceUpdated"]
referenceType = ["branch"]
referenceName = ["main"]
}
})
}
resource "aws_cloudwatch_event_target" "codepipeline_target" {
rule = aws_cloudwatch_event_rule.codecommit_rule.name
arn = aws_codepipeline.hugos3blog.arn
role_arn = aws_iam_role.cwe_pipeline_role.arn
target_id = "codepipeline-CICD"
}
With this in place, every time I merge a pull request into the main
branch, the CodePipeline workflow will trigger the automatic deployment.
Set up AWS budget alerts
Finally, I configure a couple of budget alerts on my AWS account to send me notification emails when my actual spending exceeds US$0.01
and forecasted spending is exceeds US$1
. This helps to monitor the spending on my blog site.
budgets.tf
resource "aws_budgets_budget" "zero_spend" {
name = "My Zero-Spend Budget"
budget_type = "COST"
limit_amount = "0.01"
limit_unit = "USD"
time_unit = "MONTHLY"
time_period_start = formatdate("YYYY-MM-DD_hh:mm", timestamp())
notification {
comparison_operator = "GREATER_THAN"
threshold = 0.01
threshold_type = "ABSOLUTE_VALUE"
notification_type = "ACTUAL"
subscriber_email_addresses = var.subscriber_email_addresses
}
lifecycle {
ignore_changes = [
time_period_start
]
}
}
resource "aws_budgets_budget" "one_dollar" {
name = "My Monthly Cost Budget"
budget_type = "COST"
limit_amount = "1.00"
limit_unit = "USD"
time_unit = "MONTHLY"
time_period_start = formatdate("YYYY-MM-DD_hh:mm", timestamp())
notification {
comparison_operator = "GREATER_THAN"
threshold = 85
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = var.subscriber_email_addresses
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_email_addresses = var.subscriber_email_addresses
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = var.subscriber_email_addresses
}
lifecycle {
ignore_changes = [
time_period_start
]
}
}
Deploying the infrastructure code
The complete Terraform code for my blog site deployment is available on my GitHub repo.
Follow these steps to deploy the infrastructure code.
- Clone the git repository and initialise Terraform.
git clone https://github.com/wtkhoo/hugo-blog-cicd.git
cd hugo-blog-cicd
terraform init
- Create a
terraform.tfvars
file to customise your deployment.
domain_name = "blog.wkhoo.com"
subscriber_email_addresses = ["email@wkhoo.com"]
use_default_domain = false
hosted_zone = "wkhoo.com"
acm_certificate_domain = "blog.wkhoo.com"
tags = {
"Environment" = "Test"
}
- Configure Terraform AWS provider with your AWS credentials.
export AWS_ACCESS_KEY_ID="awsaccesskey"
export AWS_SECRET_ACCESS_KEY="awssecretaccesskey"
export AWS_REGION="ap-southeast-2"
- Run a
terraform plan
command to preview what will be created.
terraform plan -out=terraform.tfplan
- If there are no errors, run the
terraform apply
command to provision the resources.
terraform apply terraform.tfplan
Wrap up
This completes the deployment of a Hugo static blog on AWS! Stay tuned for my next post where I will continue to walk through some extra bits and dive deeper into my blog site architecture design.