Skip to main content
  1. Posts/

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

·5 mins

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 #

ItemWhereCost
Domain (yearly)Any registrarone-time-ish per year
S3 bucket (a few hundred KB of HTML)AWS~a rupee a month
CloudFront distributionAWS globalfree tier first year, then a few rupees/month
ACM TLS certificateAWSfree, auto-renews
DNSRegistrar’s built-in DNSfree
Static site generatorHugo + a themefree & 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.yourdomain at the CloudFront domain, and forwards the apex (yourdomain) to www with 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.yourdomain and 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 + www variant
  • 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:

TypeNameValue
CNAMEwwwd3xxxxxxxxx.cloudfront.net
Forwardapex yourdomain301 → 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.