How I made this page

In response to the questions about my previous post that I received, here’s a detailed explanation of how I set up and deployed this site.

Step 1: Setting up Hugo

To kick things off, I followed the steps in the Hugo quick start guide to get a new site up and running.

hugo new site blog

I chose the Hugo blog awesome theme for its features and ease of use.

hugo mod init github.com/<user>/<repo>
hugo mod get github.com/hugo-sid/hugo-blog-awesome

Next, I added the theme to my hugo.toml configuration to integrate it with my site:

[module]
  [[module.imports]]
    path = "github.com/hugo-sid/hugo-blog-awesome"

For local development, I run the following command:

hugo server -D

This spins up a local server, allowing me to see my changes in real-time, including drafts that aren’t ready for publication yet.

After looking through the Hugo Blog Awesome theme docs and code, I customized the theme to fit what I wanted. I did this by copying a file from the theme into the layouts or assets folders in my project. It’s a great feature from Hugo that makes theme customization easy.

Step 2: Setting up CI/CD with Terraform

I started building the CI/CD pipeline with Terraform, first by setting up the environment to store the state in an S3 bucket.

provider "aws" {
  region = "us-east-1"

  default_tags {
    tags = {
      provider = "tf"
    }
  }
}

terraform {
  backend "s3" {
    bucket = "<name>"
    key    = "tf/terraform.tfstate"
    region = "us-east-1"
    encrypt = true
  }
}

I picked the us-east-1 region because I’ve run into issues with ACM in other regions before and didn’t want to deal with that again. Though, I’m thinking of changing it down the line.

Next, I set up an S3 bucket for the blog:

resource "aws_s3_bucket" "blog" {
  bucket = "<name>"
}

To streamline the deployment process upon each push, automating it through the CI/CD pipeline was essential. This required the creation of a user with specific permissions, as well as securely storing the user’s credentials within the pipeline. I created a user and granted the necessary permissions for deployment, focusing on ensuring operational functionality. I opted to provide broad access, allowing for cloudfront invalidation for all resources and permitting any action on the designated S3 bucket.

resource "aws_iam_user" "hugo_deploy_user" {
  name = "hugo-deploy-user"
}

resource "aws_iam_access_key" "hugo_deploy_user_key" {
  user = aws_iam_user.hugo_deploy_user.name
}

resource "aws_iam_policy" "hugo_deploy_user_policy" {
  name        = "hugo-deploy-policy"
  description = "Policy for Hugo site deployment"
  policy      = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "s3:*"
        ]
        Effect   = "Allow"
        Resource = [
          "arn:aws:s3:::<name>/*",
          "arn:aws:s3:::<name>"
        ]
      },
      {
        Effect = "Allow"
        Action = "cloudfront:CreateInvalidation"
        Resource = "*"
      }
    ]
  })
}

Also, don’t forget to attach the policy to the user. Skipping this can lead you down a rabbit hole of debugging, wondering why your setup isn’t working. Not that I did that, of course. Nope, definitely didn’t happen.

resource "aws_iam_user_policy_attachment" "hugo_deploy_user_attachment" {
  user       = aws_iam_user.hugo_deploy_user.name
  policy_arn = aws_iam_policy.hugo_deploy_user_policy.arn
}

Finally, I needed to create outputs in Terraform to retrieve the AWS IAM user’s access key ID and secret access key, ensuring the latter was marked as sensitive to prevent it from being displayed in logs.

output "access_key_id" {
  value = aws_iam_access_key.hugo_deploy_user_key.id
}

output "secret_access_key" {
  value = aws_iam_access_key.hugo_deploy_user_key.secret
  sensitive = true
}

To view these outputs after resource creation, I used:

tf output # to see the non-sensitive values
tf output -raw secret_access_key # to specifically see the sensitive value

Mind that tf is an alias for tofu, not terraform.

With the credentials in hand, I then integrated them into GitHub Actions secrets to automate the deployment. The GitHub Actions workflow I set up triggers on pushes to the main branch and goes through steps to set up Hugo, build the site with minification, and deploy it to AWS, including invalidating the CDN to ensure updated content is served.

name: Deploy Hugo to AWS

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: 'latest'
          extended: true
      - name: Build
        run: hugo --minify
      - name: Deploy
        run: |
          hugo deploy --force --invalidateCDN --logLevel INFO          
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: us-east-1

The simplicity and efficiency of the workflow underscore the power of integrating Terraform with GitHub Actions for continuous deployment.

To confirm the deployment, I checked the S3 bucket to see the uploaded files:

aws s3 ls s3://<name>

This command allows me to verify the successful upload of the site’s files to the specified S3 bucket.

Step 3: Configuring CloudFront

The final step in deploying my site was to configure a CloudFront distribution to serve the content efficiently and securely. Here’s how I accomplished this with Terraform:

resource "aws_cloudfront_distribution" "blog_distribution" {
  origin {
    domain_name = aws_s3_bucket.blog.bucket_regional_domain_name
    origin_id   = "S3-${aws_s3_bucket.blog.bucket}"

    s3_origin_config {
      origin_access_identity = "origin-access-identity/cloudfront/${aws_cloudfront_origin_access_identity.blog_oai.id}"
    }
  }

  enabled             = true
  is_ipv6_enabled     = true
  comment             = "CloudFront Distribution for blog.bwozniak.com"
  default_root_object = "index.html"

  aliases = ["blog.bwozniak.com"]

  default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "S3-${aws_s3_bucket.blog.bucket}"

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }

  viewer_certificate {
    acm_certificate_arn = aws_acm_certificate.ssl_certificate.arn
    ssl_support_method  = "sni-only"
  }

  custom_error_response {
    error_code         = 404
    response_page_path = "/404.html"
    response_code      = 404
    error_caching_min_ttl = 300
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}

Creating this CloudFront distribution required an SSL/TLS certificate for secure HTTPS connections. I obtained this certificate through AWS Certificate Manager (ACM) and validated it using DNS through Route 53.

resource "aws_acm_certificate" "ssl_certificate" {
  domain_name       = "blog.bwozniak.com"
  validation_method = "DNS"
}

locals {
  validation_records = tolist(aws_acm_certificate.ssl_certificate.domain_validation_options)
}

resource "aws_route53_record" "ssl_certificate_validation" {
  count = length(local.validation_records)

  name    = local.validation_records[count.index].resource_record_name
  zone_id = data.aws_route53_zone.zone.zone_id
  type    = local.validation_records[count.index].resource_record_type
  records = [local.validation_records[count.index].resource_record_value]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "ssl_certificate_validation" {
  certificate_arn         = aws_acm_certificate.ssl_certificate.arn
  validation_record_fqdns = [for record in aws_route53_record.ssl_certificate_validation : record.fqdn]
}

This setup ensures that my site is served quickly and securely to users worldwide, with SSL encryption and optimized content delivery through CloudFront.

Integrating a custom domain with your CloudFront distribution requires a few steps in Terraform, starting with setting up the domain in Route 53.

First, I needed to declare the Route 53 zone in Terraform to manage DNS settings for my domain:

data "aws_route53_zone" "zone" {
  name = "bwozniak.com"
}

This code snippet retrieves information about an existing Route 53 zone, preparing it for use in setting up DNS records.

After setting up the CloudFront distribution, I discovered that the site wasn’t accessible due to missing DNS records. The solution was to add a CNAME record pointing to the CloudFront distribution:

resource "aws_route53_record" "cloudfront_cname" {
  zone_id = data.aws_route53_zone.zone.zone_id
  name    = "blog.bwozniak.com"
  type    = "CNAME"
  ttl     = 300

  records = [aws_cloudfront_distribution.blog_distribution.domain_name]
}

To ensure your S3 bucket properly serves my website, I configured it for website hosting, specifying the index and error documents:

resource "aws_s3_bucket_website_configuration" "blog" {
  bucket = aws_s3_bucket.blog.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "404.html"
  }
}

resource "aws_s3_bucket_ownership_controls" "blog" {
  bucket = aws_s3_bucket.blog.id

  rule {
    object_ownership = "BucketOwnerPreferred"
  }
}

After I got the DNS records sorted, my site was finally accessible. The homepage loaded just fine, but when I tried accessing any subpages directly, I hit a CloudFront error page. Initially puzzled, I experimented by appending index.html to the subpage URLs, which surprisingly worked since Hugo generates an index.html for each subdirectory in the public directory by default.

Despite various attempts to solve this issue, I couldn’t crack it on my own. Some research and conversations with AI later, I stumbled upon AWS Lambda@Edge, a service that seemed to offer exactly what I needed. So, I set up a Lambda function designed to append index.html to any request that didn’t point directly to a file:

resource "aws_lambda_function" "edge" {
  function_name = "cloudfront-url-rewrite"
  runtime       = "nodejs20.x"
  handler       = "cf.handler"
  role          = aws_iam_role.lambda_exec.arn
  filename      = data.archive_file.lambda_zip.output_path
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256

  publish = true
}

To keep the Lambda function updated, I used Terraform to zip its code and monitor for changes through hashing:

data "archive_file" "lambda_zip" {
  type        = "zip"
  source_file = "${path.module}/cf.js"
  output_path = "${path.module}/cf.zip"
}

The JavaScript function itself was straightforward:

'use strict';

exports.handler = (event, context, callback) => {
    const request = event.Records[0].cf.request;
    if (request.uri.endsWith('/') || !request.uri.includes('.')) {
        request.uri += 'index.html';
    }
    callback(null, request);
};

Next, I had to ensure the Lambda function had the necessary permissions and was correctly associated with my execution role:

resource "aws_iam_role" "lambda_exec" {
  name = "lambda_exec_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Action = "sts:AssumeRole",
        Principal = {
          Service = "lambda.amazonaws.com",
        },
        Effect = "Allow",
        Sid    = "",
      },
      {
        Effect = "Allow",
        Principal = {
          Service = "edgelambda.amazonaws.com",
        },
        Action = "sts:AssumeRole",
      },
    ],
  })
}

resource "aws_iam_policy" "lambda_policy" {
  name        = "lambda_policy"
  description = "IAM policy for logging from a Lambda"

  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents",
        ],
        Resource = "arn:aws:logs:*:*:*",
        Effect   = "Allow",
      },
    ],
  })
}

resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.lambda_exec.name
  policy_arn = aws_iam_policy.lambda_policy.arn
}

Finally, I linked the Lambda function to my CloudFront distribution to handle viewer requests:

resource "aws_cloudfront_distribution" "blog_distribution" {
  ...
  default_cache_behavior {
    ...
    lambda_function_association {
      event_type   = "viewer-request"
      lambda_arn   = aws_lambda_function.edge.qualified_arn
      include_body = false
    }
    ...
  }
  ...
}

With this setup, subpages loaded without any errors, seamlessly integrating with the rest of the site.

Infracost

To keep track of the costs associated with my infrastructure, I used Infracost, a tool that provides cost estimates for Terraform projects. By running the following command, I could see the projected costs for my setup:

infracost breakdown --path ./tf --usage-file ./tf/infracost-usage.yml

Where ./tf is the directory containing my Terraform files and ./tf/infracost-usage.yml is the file specifying the usage details for the resources. It looks something like this:

version: 0.1

resource_usage:
  aws_lambda_function.edge:
    monthly_requests: 30000
    request_duration_ms: 100
  aws_cloudfront_distribution.blog_distribution:
    monthly_data_transfer_to_internet_gb:
      us: 1
  ...

I assumed that I’ll become super popular and get 30,000 requests per month, with each request taking 100ms to process. I also estimated 10GB of data transfer to the internet per month. These values are rough estimates, but they give me a good idea of what to expect in terms of costs if the traffic increases. The output I got:

┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ Project   ┃ Baseline cost ┃ Usage cost* ┃ Total cost ┃
┣━━━━━━━━━━━╋━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━━━━━━━━┫
┃ .../tf    ┃ $0.00         ┃ $3          ┃ $3         ┃
┗━━━━━━━━━━━┻━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━━━━━━━━┛

I think it’s as low as it can get by using Route 53, ACM, S3, CloudFront, and Lambda@Edge. I’m happy with the costs, and I’m confident that the setup will scale well if the traffic increases.