Building a Cost-Optimized Static Blog on AWS with Custom Domain
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:
- Go to Route 53 Console → Registered Domains
- Click your domain → Edit name servers
- Replace with the nameservers from CDK output
If registered elsewhere (Namecheap, GoDaddy, etc.):
- Log into your registrar
- Find DNS/Nameserver settings
- Change to "Custom Nameservers"
- 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:
- Create IAM role with trust policy for GitHub OIDC
- Attach permissions for S3 and CloudFront
- 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
- Full source code on GitHub
- AWS CDK Documentation
- Next.js Static Exports
- AWS Certificate Manager
- Route 53 Documentation
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!