Hosting a Static Blog on AWS for Under $5/Month
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:
- Register GitHub as an IAM identity provider
- Create an IAM role with a trust policy scoped to your specific repo
- Attach S3 and CloudFront permissions
- 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.