This Blog Runs on AWS for About a Dollar a Month

S3, CloudFront and Route 53, all in Terraform, with no servers and no stored credentials — and the DNS mistake that would have silently killed my email.

Before writing about anything else, I had to put the site somewhere. The obvious temptation for someone who works with infrastructure all day is to over-build it — an EC2 instance, maybe a small EKS cluster, “for practice.”

I want to argue against that, because I nearly did it.

The bill is the constraint

A blog with no readers still costs whatever its infrastructure costs. A t3.small running full-time is roughly $15/month. Put it behind an ALB and you’ve added another $16. Give it a managed database it doesn’t need and you’re at $60/month for a site that serves a few hundred static pages.

Static hosting behind a CDN, for the same content:

ItemMonthly
Route 53 hosted zone$0.50
Route 53 queries~$0.01
S3 storage + requests~$0.05
CloudFront$0.00 until 1 TB/month egress
ACM certificate$0.00
Total~$0.60

The interesting part isn’t the saving. It’s that the cheap version is also the one with no servers to patch, no runtime to exploit, and no bill that grows if a post gets attention.

The shape of it

Route 53
  └─> CloudFront ── OAC ──> S3 (private bucket)
        ├─ ACM certificate (us-east-1)
        ├─ CloudFront Function: www redirect + pretty URLs
        └─ Response headers policy: HSTS, CSP, nosniff, frame-deny

GitHub Actions ── OIDC ──> S3 sync + cache invalidation

All of it in Terraform. A few decisions in there are worth explaining, because they’re the ones I got wrong first.

The bucket is not public

Most “static site on S3” tutorials tell you to enable S3 website hosting and attach a public-read bucket policy. That works, and it means anyone can address your bucket directly, bypassing the CDN entirely.

The alternative is an Origin Access Control. CloudFront signs its requests to S3 with SigV4, and the bucket policy trusts only requests carrying one specific distribution ARN:

statement {
  sid    = "AllowCloudFrontRead"
  effect = "Allow"

  principals {
    type        = "Service"
    identifiers = ["cloudfront.amazonaws.com"]
  }

  actions   = ["s3:GetObject"]
  resources = ["${aws_s3_bucket.site.arn}/*"]

  condition {
    test     = "StringEquals"
    variable = "AWS:SourceArn"
    values   = [aws_cloudfront_distribution.site.arn]
  }
}

Public access is blocked at all four levels on the bucket. There is no URL that reaches the objects directly.

This creates one wrinkle. Without s3:ListBucket, S3 answers 403 for an object that doesn’t exist — it won’t confirm absence to a caller that can’t list. So a missing page produces a 403, not a 404, and both have to be mapped to the error page:

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

Pretty URLs without the website endpoint

Dropping S3 website hosting costs you one useful behaviour: /posts/thing/ resolving to /posts/thing/index.html. The REST endpoint won’t do that.

A CloudFront Function handles it at the edge, for about $0.10 per million requests, and folds in the www redirect while it’s there:

var uri = request.uri;

if (uri.endsWith('/')) {
    request.uri = uri + 'index.html';
} else if (!uri.includes('.')) {
    request.uri = uri + '/index.html';
}

The dot check is what stops it mangling /style.css and /robots.txt.

No AWS keys in GitHub

The deploy pipeline authenticates with OIDC rather than a stored access key. The role’s trust policy pins the subject to one repository and one branch:

condition {
  test     = "StringEquals"
  variable = "token.actions.githubusercontent.com:sub"
  values   = ["repo:${var.github_repo}:ref:refs/heads/${var.github_branch}"]
}

Without that condition, any GitHub Actions workflow in any repository on earth could assume the role. It’s the single most important line in the file.

The part that nearly broke my email

Here is the actual lesson, and it has nothing to do with hosting.

My domain is registered at GoDaddy, and I have a mailbox on it. The DNS was already delegated to Route 53 — and that same hosted zone was quietly serving the MX records my email depends on:

MX   0   smtp.secureserver.net
MX   10  mailstore1.secureserver.net
TXT      "v=spf1 include:secureserver.net -all"
TXT      "T5601429"

Two traps here.

The first: my Terraform was written to create a hosted zone. Applying that would have made a second zone with different nameservers. The registrar still points at the original, so the new zone would be inert — the site would never resolve, and I’d have spent an evening debugging CloudFront for a DNS reason.

The second is nastier. Route 53 permits exactly one TXT record set per name. Both of those TXT values — the SPF policy and GoDaddy’s ownership token — live in a single record set. Declaring only the SPF value in Terraform doesn’t add a record alongside the other one. It replaces the set, and the ownership token disappears.

And unlike a broken web server, broken mail is silent. No 500 page, no alarm. The sender gets a bounce; you get nothing. You find out days later when you notice you haven’t heard from anyone.

So the mail records are declared in Terraform, with every value listed, and with a guard rail:

lifecycle {
  prevent_destroy = true
}

The rule I’d take from this: if Terraform owns a hosted zone, it should own every record in it. A record that exists but isn’t declared is a trap for whoever tidies up the config next — including you, in six months.

What’s still missing

Those are the next three posts.

#aws #terraform #cloudfront #dns #cost