This is a small site generated using Hugo, a lightweight framework for generating static websites (a combination of HTML, CSS, JavaScript, and content like image or video files; which does not require an active server to host).

When I write new content, I run Hugo on my laptop to “compile” it (generate the static HTML etc.). The actual content is written in Markdown. For example, the first part of this page looks like:

---
title: "How This Site Is Hosted"
date: 2025-07-24T08:18:25-07:00
---

This is a small site generated using [Hugo](https://gohugo.io/), a lightweight
...

I can run hugo server to serve the content locally with live reloading, so i can preview how everything will look once I’m done. Then, I just run hugo build and hugo deploy to generate new final content and upload it to S3, and then trigger a Cloudfront Invalidation.

S3

S3 is Amazon’s oldest AWS service, described as “an object storage service”. Basically, it’s a system where I can give any chunk of data I want a name, upload it to S3, and then retrieve it quickly and cheaply from anywhere with a variety of mechanisms.

S3 objects are grouped first into “buckets”, so let’s imagine I have a bucket named “orange-aardvark” and I want to store “index.html” in it. It would have a key like s3://orange-aardvark/index.html, and the Object at that key is a plain old HTML file <html><head>.... You can access it via S3’s own protocol, but if it’s set up for it, you could also access it via the URL https://orange-aardvark.s3-website-us-east-1.amazonaws.com.

On its own, this is basically enough to host a website, but it’s also limited in two important ways: (1) I can’t just point my domain name at this, for technical reasons I’ll skip over; and (2) Every time someone requests a file from this, I get charged. The costs are very tiny — $0.023 per GiB and $0.0004 per 1,000 requests — but if my website ends up on the front page of fark.com, I could end up with a pretty hefty bill.

The S3 Configuration looks something like this, expressed in terraform:

# Create an S3 Bucket
resource "aws_s3_bucket" "example" {
  bucket = "example"
}

# Block public access directly to the new bucket
resource "aws_s3_bucket_public_access_block" "example" {
  bucket                  = aws_s3_bucket.example.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Describe an IAM policy that allows Cloudfront to access the bucket
data "aws_iam_policy_document" "example--cloudfront" {
  statement {
    sid = "allow-cloudfront"
    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }
    actions   = ["s3:GetObject", "s3:ListBucket"]
    resources = ["${aws_s3_bucket.example.arn}/*", aws_s3_bucket.example.arn]
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values = [
        aws_cloudfront_distribution.example.arn,
      ]
    }
  }
}

# Allow Cloudfront to access the bucket
resource "aws_s3_bucket_policy" "example--cloudfront" {
  bucket = aws_s3_bucket.example.id
  policy = data.aws_iam_policy_document.example--cloudfront.json
}

Cloudfront

Enter Cloudfront, Amazon’s Content Delivery Network (CDN). CDN has a lot of power that we won’t be using, we will be using a very simple use case: A Cloudfront Distribution that is configured to cache everything from its Origin. The Origin is our S3 bucket.

This solves both of our problems (1) and (2):

(1) Cloudfront knows about the domain name quiet.ink, and will correctly map requests to the origin.

(2) Cloudfront’s most important purpose is caching. Cloudfront still costs money, but it’s incredibly cheap. (It’s complicated to figure out how much it costs, but for most small blogs it’s probably actually zero: The first 1TiB of data and the first 10 million requests are free. After that, it’s $0.085 per GB and $0.0075 per 10,000 requests.)

The Cloudfront configuration looks like this:

# this tells cloudfront how to authenticate to the S3 bucket
resource "aws_cloudfront_origin_access_control" "example" {
  name                              = "example"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "example" {
  # the "origin" is where cloudfront fetches data from that it caches
  origin {
    # this is the domain name of the S3 bucket
    domain_name              = aws_s3_bucket.example.bucket_regional_domain_name

    # this tells cloudfront how to authenticate to the S3 bucket
    origin_access_control_id = aws_cloudfront_origin_access_control.example.id

    # and this is just a name that we reference elsewhere
    origin_id                = "example"
  }

  enabled = true
  aliases = ["quiet.ink"]

  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "example"

    # "forwarding" is the opposite of caching. We don't want to forward
    # anything. We want to cache everything.
    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    # If a user loads http://example.com, redirect them to https://example.com
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
      locations        = []
    }
  }

  viewer_certificate {
    # I left this out of this post, but we also do some DNS configuration and
    # get an SSL certificate from AWS
    acm_certificate_arn = aws_acm_certificate.example.arn

    # This requires that viewers (your browser) use a technology called SNI so
    # that cloudfront can identify the distribution you want to access. The
    # alternative is to use a dedicated IP, a "VIP", which costs $600/mo 😅
    ssl_support_method  = "sni-only"
  }

There are some details I left out, but maybe I’ll expand this later with every single detail:

  • configuring DNS to point to cloudfront
  • configuring an ACM certificate so that TLS/SSL (https://) works
  • a lambda@edge function that translates a request for example.com/blah/ to example.com/blah/index.html.

All Together

This diagram shows how the process works.

sequenceDiagram actor Author create participant Hugo Author->>Hugo: Write some new content create participant S3 Hugo->>S3: Compile and Upload create participant Cloudfront Hugo->>Cloudfront: Clear Cache actor Reader Reader->>Cloudfront: Request Page Cloudfront->>S3: Fetch Page destroy S3 S3->>Cloudfront: Return Page Cloudfront->>Reader: Return Page Reader->>Cloudfront: Request Page Cloudfront->>Reader: Return Page