From a Paid Blog Platform to (Almost) Free: Hosting a Blog on AWS + DNS

For years my blog lived on a hosted platform. It looked fine, wrote well, and cost me a monthly subscription just to keep the domain and a few pages alive. This year the renewal bill went up again, and I thought — why am I paying rent every month for a handful of posts?
So over one evening, I moved everything to my own domain, my own AWS account, and a static site generator. Total ongoing cost: less than a cup of coffee per month. Here’s how it works.
The bill of materials #
| Item | Where | Cost |
|---|---|---|
| Domain (yearly) | Any registrar | one-time-ish per year |
| S3 bucket (a few hundred KB of HTML) | AWS | ~a rupee a month |
| CloudFront distribution | AWS global | free tier first year, then a few rupees/month |
| ACM TLS certificate | AWS | free, auto-renews |
| DNS | Registrar’s built-in DNS | free |
| Static site generator | Hugo + a theme | free & open source |
Compared to a monthly SaaS subscription, the difference is night and day. AWS charges by usage — a personal blog barely uses anything.
The architecture #
Reader's browser
│
▼
https://www.yourdomain
│
▼
Registrar DNS (CNAME www → CloudFront)
│
▼
Amazon CloudFront (HTTPS, ACM cert, global CDN)
│ OAC (Origin Access Control)
▼
Private S3 Bucket (static HTML/CSS/JS built by Hugo)
- S3 stores the generated site (private — no public bucket).
- CloudFront sits in front, serves over HTTPS, caches globally.
- ACM issues the TLS cert for free.
- Registrar DNS points
www.yourdomainat the CloudFront domain, and forwards the apex (yourdomain) towwwwith a 301.
The whole thing is boring, standard, and cheap.
Step 1 — Build the site locally with Hugo #
Hugo is a single binary, ridiculously fast, and blog themes are plentiful.
brew install hugo
hugo new site my-blog --format yaml
cd my-blog
git submodule add <theme-repo-url> themes/<theme>
Then write posts in Markdown under content/posts/, run hugo server, and preview at http://127.0.0.1:1313/.
Step 2 — Create the S3 bucket #
Bucket names are globally unique across all of AWS. The bucket name doesn’t need to match your domain when CloudFront is the origin — pick anything memorable.
aws s3api create-bucket \
--bucket my-blog-bucket \
--region <your-region> \
--create-bucket-configuration LocationConstraint=<your-region>
aws s3api put-public-access-block \
--bucket my-blog-bucket \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Block all public access. CloudFront reads via Origin Access Control (OAC) — the modern replacement for OAI. No s3-website hosting, no public buckets, no exposed endpoints.
Step 3 — Request an ACM certificate #
CloudFront only accepts certificates from the us-east-1 region, regardless of where your bucket lives.
aws acm request-certificate \
--domain-name yourdomain \
--subject-alternative-names www.yourdomain \
--validation-method DNS \
--region us-east-1
ACM returns two CNAME records to prove you own the domain. Copy them into your registrar’s DNS.
Common gotcha: In many registrar UIs, the “Name” field is relative to your domain. If ACM tells you the record is
_xxx.yourdomain, enter just_xxx— otherwise the record ends up as_xxx.yourdomain.yourdomainand validation fails silently.
Once both CNAMEs propagate (usually a few minutes), the cert flips from PENDING_VALIDATION to ISSUED.
Step 4 — Create the CloudFront distribution #
The important bits:
- Origin = S3 bucket, accessed via OAC (not public URL, not OAI)
- Aliases = your bare domain +
wwwvariant - Viewer certificate = the ACM cert from step 3
- Default root object =
index.html - HTTPS redirect, HTTP/2 + /3, IPv6 enabled
- Cheapest price class if you don’t need every edge location
Then attach a bucket policy that only lets this specific distribution read files:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-blog-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::<account-id>:distribution/<dist-id>"
}
}
}]
}
Step 5 — The CloudFront Function that saved my life #
Out of the box, CloudFront’s “default root object” only applies to /. That means https://www.yourdomain/posts/ asks S3 for a file literally named posts/, gets a 403, and returns 404 to the visitor.
Fix: a tiny CloudFront Function that runs on every viewer request and rewrites clean URLs to their index.html equivalent.
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
} else if (!uri.includes('.')) {
request.uri = uri + '/index.html';
}
return request;
}
Attach to the distribution’s viewer-request event. Every Hugo/Jekyll/Astro site on S3 + CloudFront needs this.
Step 6 — Wire up DNS #
Two records at your registrar:
| Type | Name | Value |
|---|---|---|
| CNAME | www | d3xxxxxxxxx.cloudfront.net |
| Forward | apex yourdomain | 301 → https://www.yourdomain (masking OFF) |
If your registrar can’t do ALIAS-style records at the apex (many can’t), use their built-in domain forwarding to redirect the naked domain to www. Slightly less elegant than an ALIAS, but free — and hosted DNS elsewhere would add a small monthly fee for a hobby site.
Step 7 — The one-command deploy #
Everything above is a one-time setup. Day to day, publishing a new post is one command:
#!/usr/bin/env bash
set -euo pipefail
hugo --minify --gc
aws s3 sync public/ s3://my-blog-bucket/ --delete \
--exclude "*" --include "*.html" --include "*.xml" --include "*.json" \
--cache-control "public, max-age=300, must-revalidate"
aws s3 sync public/ s3://my-blog-bucket/ --delete \
--exclude "*.html" --exclude "*.xml" --exclude "*.json" \
--cache-control "public, max-age=31536000, immutable"
aws cloudfront create-invalidation \
--distribution-id <dist-id> --paths "/*"
- HTML files get a short cache (5 min) so edits appear fast.
- Fingerprinted assets (CSS/JS/images) get a year-long immutable cache — Hugo hashes filenames on every build, so it’s safe.
- CloudFront invalidation forces edge caches to refresh HTML immediately.
Run ./deploy.sh after every edit. Full pipeline runs in about 15 seconds.
What it costs, honestly #
For the first year you’re inside CloudFront’s free tier (1 TB out/month, 10M requests). After that, for a personal blog with a few hundred visits a month, we’re talking single-digit rupees per month. The domain is by far the biggest cost — and it’s the same whether you host on any SaaS platform, GitHub Pages, or your own AWS account.
Would I do this again? #
Yes, without a second thought. It took an evening to set up. It’s mine — I own the domain, I own the content, I can move it to any other host in a day. And I’ll never get another “your subscription is about to renew” email.
If you’re on a hosted platform and thinking of moving — this is the path. Static site + AWS + your own domain. Boring, cheap, and yours.