Back to home

Hosting a Static Blog on AWS for Under $5/Month

Zhenyu Wen
#AWS#Next.js#Infrastructure#CDK#DevOps

Hosting a Static Blog on AWS for Under $5/Month

I wanted a personal blog that I owned completely — no platform lock-in, no monthly SaaS fee, no CMS to manage. Static files on S3 behind CloudFront fit the bill. This post walks through how I built it, including the parts that weren't as straightforward as the docs make them sound.

Architecture Overview

The final stack:

  • Next.js 14 — static site generation with React
  • Amazon S3 — static file hosting (~$0.50/month)
  • CloudFront — global CDN with HTTPS (~$1-2/month)
  • Route 53 — DNS management ($0.50/month + domain registration)
  • AWS Certificate Manager — free SSL certificates
  • AWS CDK — infrastructure as code in TypeScript
  • GitHub Actions — automated CI/CD pipeline

Total monthly cost: ~$2.75-4.50/month (plus domain registration ~$13/year)

Part 1: Building the Static Site

Next.js with Static Export

I chose Next.js for file-based routing, static generation, and good developer experience. The critical config in next.config.js:

module.exports = {
  output: 'export',  // generates static HTML/CSS/JS
  images: {
    unoptimized: true,  // required for static export
  },
}

Markdown-Based Content System

All blog posts live in the posts/ directory as Markdown files with frontmatter:

---
title: "Your Post Title"
date: "2024-01-20"
author: "Zhenyu Wen"
excerpt: "Brief description"
tags:
  - AWS
  - Infrastructure
---

Your content here in **Markdown**!

I built a simple content system using gray-matter and remark:

// lib/posts.ts
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';

export async function getPostData(id: string) {
  const fileContents = fs.readFileSync(`posts/${id}.md`, 'utf8');
  const matterResult = matter(fileContents);

  const processedContent = await remark()
    .use(html, { sanitize: false })
    .process(matterResult.content);

  return {
    id,
    contentHtml: processedContent.toString(),
    ...matterResult.data
  };
}

Write a post, commit it, push — CI handles the rest.

Part 2: AWS Infrastructure with CDK

I chose AWS CDK (TypeScript) over CloudFormation or Terraform because it's type-safe, uses a familiar language, and gives you real IDE support. For a blog this simple it might seem like overkill, but the reproducibility and version control are worth the upfront investment.

The S3 Bucket

this.bucket = new s3.Bucket(this, 'WebsiteBucket', {
  bucketName: props.bucketName,
  websiteIndexDocument: 'index.html',
  websiteErrorDocument: '404.html',
  publicReadAccess: true,
  removalPolicy: cdk.RemovalPolicy.RETAIN,  // don't delete on stack deletion
});

Set removalPolicy: RETAIN. I learned this the hard way — without it, a cdk destroy or accidental stack update can wipe your content.

CloudFront Distribution

this.distribution = new cloudfront.Distribution(this, 'WebsiteDistribution', {
  defaultBehavior: {
    origin: new origins.S3Origin(this.bucket),
    viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
    compress: true,
  },
  defaultRootObject: 'index.html',
  priceClass: cloudfront.PriceClass.PRICE_CLASS_100,  // North America + Europe only
  errorResponses: [
    {
      httpStatus: 404,
      responseHttpStatus: 200,
      responsePagePath: '/index.html',
      ttl: cdk.Duration.minutes(5),
    },
  ],
});

PRICE_CLASS_100 limits edge locations to North America and Europe, which cuts cost while covering the majority of readers.

Part 3: Adding Custom Domain with SSL

Step 1: Request SSL Certificate in ACM

ACM certificates are free for CloudFront. The one hard requirement: the certificate must be in us-east-1, regardless of where the rest of your infrastructure lives.

aws acm request-certificate \
  --domain-name yourdomain.com \
  --subject-alternative-names www.yourdomain.com \
  --validation-method DNS \
  --region us-east-1

Step 2: DNS Validation

ACM validates domain ownership via a CNAME record:

Name: _abc123.yourdomain.com
Type: CNAME
Value: _xyz789.acm-validations.aws.

Add this to your Route 53 hosted zone (or your registrar's DNS panel if the domain is registered elsewhere). Validation typically takes 5-30 minutes once the record is in place. If it's stuck in "Pending Validation" after an hour, the CNAME is usually wrong — double-check for typos and trailing dots.

Step 3: Update CDK Infrastructure

let certificate: acm.ICertificate | undefined;
let domainNames: string[] | undefined;

if (props.customDomain && props.certificateArn) {
  certificate = acm.Certificate.fromCertificateArn(
    this,
    'Certificate',
    props.certificateArn
  );
  domainNames = [props.customDomain, `www.${props.customDomain}`];
}

this.distribution = new cloudfront.Distribution(this, 'WebsiteDistribution', {
  // ... other config
  domainNames: domainNames,
  certificate: certificate,
});

Step 4: Route 53 DNS Records

if (props.customDomain) {
  this.hostedZone = new route53.PublicHostedZone(this, 'HostedZone', {
    zoneName: props.customDomain,
  });

  new route53.ARecord(this, 'AliasRecord', {
    zone: this.hostedZone,
    recordName: props.customDomain,
    target: route53.RecordTarget.fromAlias(
      new targets.CloudFrontTarget(this.distribution)
    ),
  });

  new route53.ARecord(this, 'WwwAliasRecord', {
    zone: this.hostedZone,
    recordName: `www.${props.customDomain}`,
    target: route53.RecordTarget.fromAlias(
      new targets.CloudFrontTarget(this.distribution)
    ),
  });
}

Route 53 ALIAS records work for root domains (yourdomain.com) — regular CNAMEs only work for subdomains. ALIAS records are a Route 53 extension that act like A records but resolve to AWS resources.

Step 5: Deploy and Update Nameservers

cd infrastructure
export BUCKET_NAME="your-blog-bucket"
export CUSTOM_DOMAIN="yourdomain.com"
export CERTIFICATE_ARN="arn:aws:acm:us-east-1:123456789012:certificate/abc-123"
cdk deploy

CDK outputs the 4 nameservers for your hosted zone. Update them at your domain registrar. DNS propagation takes 1-48 hours (usually under 2).

Part 4: Automated CI/CD with GitHub Actions

Every push to master triggers a full build and deploy:

name: Deploy to AWS S3

on:
  push:
    branches:
      - master

permissions:
  id-token: write   # required for OIDC
  contents: read

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install and build
        run: |
          npm ci
          npm run build

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-action-role
          aws-region: us-east-1

      - name: Deploy to S3
        run: |
          aws s3 sync ./out s3://${{ secrets.S3_BUCKET_NAME }} --delete

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/*"

I used OIDC instead of long-lived AWS access keys. GitHub Actions gets a short-lived token from AWS via a trust relationship — no secrets to rotate, no credentials to accidentally expose. Setting it up takes about 10 extra minutes once:

  1. Register GitHub as an IAM identity provider
  2. Create an IAM role with a trust policy scoped to your specific repo
  3. Attach S3 and CloudFront permissions
  4. Add the role ARN to the workflow

The only GitHub secret needed is AWS_ACCOUNT_ID.

Part 5: Troubleshooting

Certificate stuck in "Pending Validation" — the CNAME validation record wasn't added correctly. Common mistakes: adding the full domain name instead of just the prefix, typos in the value, or missing trailing dot. Debug with dig _abc123.yourdomain.com CNAME +short.

"Certificate doesn't exist or isn't in us-east-1" — CloudFront requires certificates in us-east-1 specifically. Always create ACM certificates there when using CloudFront.

Domain shows "No answer" in nslookup — nameservers haven't been updated or DNS hasn't propagated yet. Check with nslookup -type=NS yourdomain.com.

TypeScript error "Cannot assign to read-only property" — CloudFront distribution props must be set during initialization, not after:

// Wrong
const config: cloudfront.DistributionProps = { /* ... */ };
config.domainNames = ['example.com'];

// Correct: build the value before passing it in
let domainNames: string[] | undefined;
if (customDomain) {
  domainNames = [customDomain, `www.${customDomain}`];
}
new cloudfront.Distribution(this, 'Distribution', { domainNames, ... });

404 after deployment — CloudFront is serving cached content. Always invalidate after deploying: aws cloudfront create-invalidation --distribution-id E1234 --paths "/*".

Cost Breakdown

Service Monthly Cost Notes
S3 Storage ~$0.50 10GB static files
S3 Requests ~$0.01 Minimal (CloudFront caches)
CloudFront ~$1-2 First 1TB free
Route 53 Hosted Zone $0.50 Per hosted zone
Route 53 Queries ~$0.01 First 1M free
ACM Certificate FREE
Domain Registration ~$1/month $13/year for .com
Total ~$2.75-4.50/month

For comparison, Vercel Pro is $20/month and Netlify Pro is $19/month.

What I'd Do Differently

DNS is the hard part. The code and AWS setup took a few hours. Waiting for DNS propagation and debugging nameserver issues took longer. Do the nameserver update first and let it propagate while you work on everything else.

Deploy without the custom domain first. I added the custom domain in a second pass after the basic S3/CloudFront setup was working. That incremental approach made troubleshooting much easier — fewer variables at each step.

Markdown in Git beats any CMS. Version controlled, editable locally, no database, fast deploys. I haven't missed a headless CMS at all.

Resources