I recently decided to migrate my blog from Wordpress to static website hosting, mostly for fun, so I found myself needing some hosting infrastructure. Since Terraform and AWS is what I know, I decided to use those tools to create a quick infra stack.

Below is the code that I used. It isn’t really built for re-use, at least not yet, but it did work for me. This hosting setup uses:

  • S3
  • CloudFront
  • ACM
  • CloudFront Functions
  • Route53

It should cost next to nothing thanks to AWS’s free-tier, but YMMV.

First, we need our bucket:

resource "aws_s3_bucket" "sharpletters_hugo" {
  bucket = "sharpletters-hugo"
}

resource "aws_s3_bucket_acl" "sharpletters_hugo" {
  bucket = aws_s3_bucket.sharpletters_hugo.id
  acl    = "private"
}

data "aws_iam_policy_document" "sharpletters_hugo" {
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.sharpletters_hugo.arn}/*"]

    principals {
      type        = "AWS"
      identifiers = ["${aws_cloudfront_origin_access_identity.sharpletters_hugo.iam_arn}"]
    }
  }

  statement {
    actions   = ["s3:ListBucket"]
    resources = ["${aws_s3_bucket.sharpletters_hugo.arn}"]

    principals {
      type        = "AWS"
      identifiers = ["${aws_cloudfront_origin_access_identity.sharpletters_hugo.iam_arn}"]
    }
  }
}

resource "aws_s3_bucket_policy" "sharpletters_hugo" {
  bucket = aws_s3_bucket.sharpletters_hugo.id
  policy = data.aws_iam_policy_document.sharpletters_hugo.json
}

Next we need a certificate, so our blog can be safely served over HTTPS:

resource "aws_acm_certificate" "sharpletters" {
  provider          = aws.us-east-1
  domain_name       = local.hosting_point
  validation_method = "DNS"
  lifecycle {
    create_before_destroy = true
  }

  subject_alternative_names = [
    "www.sharpletters.net",
  ]
}

data "aws_route53_zone" "sharpletters_net" {
  name = "sharpletters.net"
}

resource "aws_route53_record" "sharpletters_acm" {
  for_each = {
    for dvo in aws_acm_certificate.sharpletters.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.sharpletters_net.zone_id
}

resource "aws_acm_certificate_validation" "sharpletters" {
  provider                = aws.us-east-1
  certificate_arn         = aws_acm_certificate.sharpletters.arn
  validation_record_fqdns = [for record in aws_route53_record.sharpletters_acm : record.fqdn]
}

Then we need our a CloudFront distribution, as it gives us much more flexibility over hosting from S3 directly:

locals {
  s3_origin_id  = "s3"
  hosting_point = "sharpletters.net"
}

resource "aws_cloudfront_origin_access_identity" "sharpletters_hugo" {
}

resource "aws_cloudfront_distribution" "blog" {
  origin {
    domain_name = aws_s3_bucket.sharpletters_hugo.bucket_regional_domain_name
    origin_id   = local.s3_origin_id

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.sharpletters_hugo.cloudfront_access_identity_path
    }
  }

  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"

  aliases = [
    local.hosting_point,
    "www.sharpletters.net",
  ]

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.s3_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }
    compress               = true
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400

    function_association {
      event_type   = "viewer-request"
      function_arn = aws_cloudfront_function.indexer.arn
    }

    response_headers_policy_id = aws_cloudfront_response_headers_policy.blog.id
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  price_class = "PriceClass_200"

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate.sharpletters.arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
}

Because we want to attach all the fancy security headers, lets set that up in CloudFront:

resource "aws_cloudfront_response_headers_policy" "blog" {
  name = "blog"

  security_headers_config {
    content_type_options {
      override = true
    }

    frame_options {
      frame_option = "DENY"
      override     = true
    }

    strict_transport_security {
      access_control_max_age_sec = 31536000
      include_subdomains         = true
      override                   = true
      preload                    = true
    }

    xss_protection {
      mode_block = true
      override   = true
      protection = true
    }

    referrer_policy {
      override        = true
      referrer_policy = "strict-origin-when-cross-origin"
    }
  }
}

Hugo likes to serve “pretty” urls, so we need to translate those into a URI that our S3 origin will accept:

resource "aws_cloudfront_function" "indexer" {
  code    = file("${path.module}/request.js")
  name    = "indexer"
  runtime = "cloudfront-js-1.0"
  publish = true
}

And the function code, which also redirects www to the apex domain:

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

    // Check whether the URI is missing a file name.
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    }
    // Check whether the URI is missing a file extension.
    else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }

    var headers = request.headers;
    var host = request.headers.host.value;

    if (host === 'www.sharpletters.net') {
        var response = {
            statusCode: 301,
            statusDescription: 'Found',
            headers:
                { "location": { "value": 'https://sharpletters.net' } }
            }

        return response;
    }

    return request;
}

Finally, we should host the CloudFront distribution on our domain!

resource "aws_route53_record" "cf" {
  name    = local.hosting_point
  type    = "A"
  zone_id = data.aws_route53_zone.sharpletters_net.zone_id

  alias {
    evaluate_target_health = false
    name                   = aws_cloudfront_distribution.blog.domain_name
    zone_id                = aws_cloudfront_distribution.blog.hosted_zone_id
  }
}

resource "aws_route53_record" "cf_aaaa" {
  name    = local.hosting_point
  type    = "AAAA"
  zone_id = data.aws_route53_zone.sharpletters_net.zone_id

  alias {
    evaluate_target_health = false
    name                   = aws_cloudfront_distribution.blog.domain_name
    zone_id                = aws_cloudfront_distribution.blog.hosted_zone_id
  }
}