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
}
}