Back to home

Building a Cost-Optimized Static Blog on AWS with Custom Domain

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

Building a Cost-Optimized Static Blog on AWS with Custom Domain

Over the past few weeks, I built this blog using a modern static site architecture on AWS. The goal was simple: create a fast, scalable personal blog that costs less than a cup of coffee per month, with automated deployments and a custom domain.

Here's the complete journey from idea to production, including all the gotchas and lessons learned.

Architecture Overview

The final architecture uses:

  • 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

Why Next.js with Static Export?

I chose Next.js because it offers:

  • Static site generation (no server needed)
  • File-based routing
  • Built-in optimization (images, fonts, code splitting)
  • Great developer experience with hot reload

The key configuration in next.config.js:

module.exports = {
  output: 'export',  // Critical: 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: "Ray 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
  };
}

This approach means I can write posts in Markdown, commit them to Git, and let CI/CD handle deployment automatically.

Part 2: AWS Infrastructure with CDK

Why CDK over CloudFormation or Terraform?

I chose AWS CDK (TypeScript) because:

  • Type safety prevents configuration errors
  • Familiar programming language (no new DSL to learn)
  • Great IDE support with autocomplete
  • Easier to test and modularize

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
});

Key lesson: Set removalPolicy: RETAIN to avoid accidentally deleting your content when updating infrastructure.

CloudFront Distribution

CloudFront provides:

  • Global CDN for fast content delivery
  • Free HTTPS with AWS certificates
  • Custom domain support
  • Automatic HTTP to HTTPS redirect
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,  // Cost optimization
  errorResponses: [
    {
      httpStatus: 404,
      responseHttpStatus: 200,
      responsePagePath: '/index.html',  // SPA routing support
      ttl: cdk.Duration.minutes(5),
    },
  ],
});

Cost optimization: Using PRICE_CLASS_100 (North America + Europe only) instead of all edge locations saves money while covering most users.

Part 3: Adding Custom Domain with SSL

This is where things get interesting. Here's the complete flow:

Step 1: Request SSL Certificate in ACM

SSL certificates from AWS Certificate Manager are completely free for CloudFront.

Critical requirement: Certificate MUST be in us-east-1 region (CloudFront requirement).

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 requires you to prove domain ownership by adding CNAME records. The validation record looks like:

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

Where to add it: If you registered your domain in Route 53, add this CNAME to your Route 53 hosted zone. If registered elsewhere (Namecheap, GoDaddy), add it to their DNS panel.

Common issue: Certificate stuck in "Pending Validation" for days usually means the CNAME wasn't added correctly.

Validation typically takes 5-30 minutes once the CNAME is properly configured.

Step 3: Update CDK Infrastructure

Add custom domain configuration to the stack:

// Add to CloudFront distribution
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}`];
}

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

Step 4: Route 53 Hosted Zone and DNS Records

CDK automatically creates the hosted zone and all necessary DNS records:

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

  // A record (IPv4) for root domain
  new route53.ARecord(this, 'AliasRecord', {
    zone: this.hostedZone,
    recordName: props.customDomain,
    target: route53.RecordTarget.fromAlias(
      new targets.CloudFrontTarget(this.distribution)
    ),
  });

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

  // AAAA records (IPv6) - same pattern
}

Why ALIAS records instead of CNAME?

  • ALIAS records can be used for root domains (yourdomain.com)
  • CNAME records only work for subdomains (www.yourdomain.com)
  • ALIAS records are a Route 53 special feature that acts like an A record but points to AWS resources

Step 5: Deploy with Custom Domain

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 nameservers you'll need:

BlogInfrastructureStack.NameServers =
  ns-1234.awsdns-12.org,
  ns-567.awsdns-34.com,
  ns-890.awsdns-56.net,
  ns-123.awsdns-78.co.uk

Step 6: Update Nameservers

Critical step: Update your domain's nameservers to point to the Route 53 hosted zone.

If you registered the domain in Route 53:

  1. Go to Route 53 Console → Registered Domains
  2. Click your domain → Edit name servers
  3. Replace with the nameservers from CDK output

If registered elsewhere (Namecheap, GoDaddy, etc.):

  1. Log into your registrar
  2. Find DNS/Nameserver settings
  3. Change to "Custom Nameservers"
  4. Enter the 4 AWS nameservers

Wait time: DNS propagation can take 1-48 hours (usually 1-2 hours).

Part 4: Automated CI/CD with GitHub Actions

The workflow deploys automatically on every push to master:

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 "/*"

Why OIDC Instead of Access Keys?

Using OpenID Connect (OIDC) is more secure than storing AWS access keys:

  • No long-lived credentials to manage
  • Automatic rotation
  • Fine-grained IAM permissions
  • Follows AWS security best practices

Setup:

  1. Create IAM role with trust policy for GitHub OIDC
  2. Attach permissions for S3 and CloudFront
  3. Add role ARN to workflow

Part 5: Troubleshooting Common Issues

Issue 1: "Certificate doesn't exist or isn't in us-east-1"

Cause: CloudFront requires certificates in us-east-1 region specifically.

Solution: Always create ACM certificates in us-east-1 when using CloudFront:

aws acm request-certificate \
  --region us-east-1 \  # Must be us-east-1!
  --domain-name yourdomain.com

Issue 2: Domain Shows "No answer" When Using nslookup

Cause: Nameservers not updated or DNS not propagated yet.

Debug steps:

# Check current nameservers
nslookup -type=NS yourdomain.com

# Should show awsdns-*.org, awsdns-*.com, etc.
# If not, nameservers haven't been updated

Solution: Verify nameservers are updated at your domain registrar and wait for DNS propagation.

Issue 3: Certificate Stuck in "Pending Validation"

Cause: CNAME validation record not added or added incorrectly.

Common mistakes:

  • Added full domain name instead of just the prefix
  • Typo in the CNAME value
  • Added to wrong hosted zone
  • Missing trailing dot in value

Debug:

# Check if CNAME exists
dig _abc123.yourdomain.com CNAME +short

# Should return the validation value

Issue 4: TypeScript Error "Cannot assign to read-only property"

Cause: Trying to modify CloudFront distribution properties after initialization.

Wrong approach:

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

Correct approach:

let domainNames: string[] | undefined;
if (customDomain) {
  domainNames = [customDomain, `www.${customDomain}`];
}

new cloudfront.Distribution(this, 'Distribution', {
  domainNames: domainNames,  // Set during initialization
  // ... other props
});

Issue 5: 404 After Deployment Despite Files in S3

Cause: CloudFront cache serving old content.

Solution: Always invalidate CloudFront cache after deployment:

aws cloudfront create-invalidation \
  --distribution-id E1234ABCDEF \
  --paths "/*"

Cost Breakdown

Here's the actual monthly cost for this blog:

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

Annual cost: ~$33-54/year

For comparison:

  • Vercel Pro: $20/month
  • Netlify Pro: $19/month
  • WordPress hosting: $10-30/month
  • Ghost Pro: $9-199/month

Lessons Learned

1. Start Simple, Add Complexity

I deployed without a custom domain first, then added it later. This incremental approach made troubleshooting much easier.

2. Infrastructure as Code is Worth It

Writing CDK might seem like overkill for a simple blog, but it paid off:

  • Reproducible infrastructure
  • Version controlled
  • Easy to update and modify
  • Self-documenting

3. DNS is Always the Hard Part

The most time-consuming part wasn't the code or AWS setup—it was waiting for DNS propagation and debugging nameserver issues.

4. OIDC > Access Keys

Setting up OIDC for GitHub Actions took 10 extra minutes but eliminated the security risk of storing AWS credentials in GitHub secrets.

5. Markdown > CMS

Managing content in Markdown files in Git is simpler than any headless CMS:

  • Version controlled
  • Easy to edit locally
  • No database to manage
  • Fast deployments

What's Next

Potential improvements I'm considering:

  • Analytics: Add privacy-focused analytics (Plausible or self-hosted)
  • Search: Implement client-side search with Flexsearch
  • RSS Feed: Generate RSS feed from markdown posts
  • Newsletter: Integrate email subscription
  • Comments: Add privacy-focused commenting (maybe utterances)
  • Monitoring: CloudWatch alarms for high costs or errors

Conclusion

Building a static blog on AWS with a custom domain is more involved than using a platform like Medium or Substack, but you get:

  • Full control over design and functionality
  • Better performance with global CDN
  • Lower cost than managed platforms
  • No vendor lock-in - all content in markdown files
  • Learning opportunity - hands-on AWS experience

The infrastructure scales effortlessly from 10 to 10 million visitors without code changes, and costs stay predictable and low.

If you're a developer who wants to own your content and infrastructure, this stack is hard to beat.

Resources


Have questions about the setup? Ran into issues deploying your own? Feel free to reach out at wenzhenyu36@gmail.com or open an issue on GitHub!