cat blog/hosting-a-site-on-aws-for-51-cents.md
Hosting this site on AWS for $0.51 a month
This site runs on AWS and costs about fifty cents a month. Below is the whole stack, along with the bill broken down honestly.
What it costs
| Item | Monthly |
|---|---|
| Route 53 hosted zone | $0.50 |
| S3 storage and requests | ~$0.01 |
| CloudFront | $0.00 |
| ACM certificate | $0.00 |
| Total | ~$0.51 |
The hosted zone is the only line item that isn’t free. CloudFront’s free tier covers 1 TB of transfer and 10 million requests a month, and it is a perpetual pay-as-you-go tier rather than a twelve month trial. A personal site will not get close to those limits for a long time.
The shape of it
GitHub push → Actions (OIDC) → S3 (private) → CloudFront (OAC + TLS) → the site
↑
Route 53 alias
The bucket is private. It has no website endpoint and no public access. The only thing allowed to read it is CloudFront, through Origin Access Control, scoped to one distribution ARN:
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
Plenty of older tutorials tell you to make the bucket public and turn on static website hosting. It is worth avoiding, and not only because a public bucket is exposed for no reason. The S3 website endpoint cannot serve HTTPS on a custom domain, so you would need CloudFront in front of it regardless.
Gotcha one: the certificate has to live in us-east-1
CloudFront only accepts ACM certificates issued in us-east-1, no matter where your bucket is. If you issue it in your home region, the certificate will not appear in the dropdown at all, and there is no error message explaining why. In Terraform this means a second provider:
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
resource "aws_acm_certificate" "site" {
provider = aws.us_east_1
domain_name = var.domain_name
validation_method = "DNS"
}
Gotcha two: S3 origins don’t resolve directory indexes
Request /about/ from an S3 REST origin and you get a 403 rather than the
index.html sitting inside it. That behaviour only exists on the website
endpoint, which we are deliberately not using. A CloudFront Function fixes it
at the edge:
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
Related: because the bucket is private, a missing key returns 403 rather than 404, so map both to your 404 page.
Cache headers, split two ways
Astro content-hashes everything under _astro/, so those files can be cached
more or less forever. HTML cannot be, or your deploys will not show up.
aws s3 sync dist/ "s3://$BUCKET/" --delete \
--exclude "*" --include "_astro/*" \
--cache-control "public,max-age=31536000,immutable"
aws s3 sync dist/ "s3://$BUCKET/" --delete \
--exclude "_astro/*" \
--cache-control "public,max-age=0,must-revalidate"
No access keys
GitHub Actions authenticates with OIDC. GitHub presents a short-lived token, AWS trades it for temporary credentials, and the trust policy pins it to one repository:
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:devopswithlijo/website:*"]
}
Nothing long-lived is stored in the repo. If someone gets hold of your repo secrets, there is no AWS key in there to steal.
Before you walk away
Set a billing alarm. The cost here really is pennies, but forgetting about a NAT gateway is one of the most common ways people end up with a surprise AWS bill. Go to Budgets, create a $5 monthly budget with an email alert, and you are done in about two minutes.