Building a Modern Serverless Blog: The Complete Architecture
When I set out to build my personal blog at wayne.theworkmans.us, I wanted to create something that wasn't just functional, but also demonstrated modern cloud architecture best practices. What emerged is a fully serverless, secure, and scalable solution that leverages the best of AWS services while maintaining cost efficiency. Let me walk you through the complete architecture and the interesting technical decisions I made along the way.
Frontend Architecture: Simplicity Meets Performance
The Radical Decision: No Build Tools
In an era of webpack, React, and complex build pipelines, I made a deliberately contrarian choice: pure HTML, CSS, and vanilla JavaScript. No npm, no node_modules, no build step. This might seem primitive, but it's actually quite liberating:
- Zero build time - changes are instant
- No dependency vulnerabilities to manage
- Debugging is straightforward - what you write is what runs
- The entire blog is under 100KB (excluding media)
Inline CSS: Solving the Cache Problem
Every page includes its styles inline within <style> tags. While this goes against conventional wisdom about CSS reusability, it solves a real problem I encountered: aggressive CDN caching. When styles are inline, updating a page guarantees users get the new styles. No more "clear your cache" support requests!
<style>
/* Styles are embedded directly in each HTML file */
header {
background: white;
position: sticky;
top: 0;
z-index: 100;
}
</style>
Dynamic Blog Loading with Static Files
The homepage dynamically loads blog posts, but not through an API - instead, it fetches a static metadata.json file. This clever approach gives me the benefits of dynamic content while maintaining the simplicity and cacheability of static files:
// From blog.js - fetching and rendering posts
const response = await fetch('/posts/metadata.json');
const data = await response.json();
const posts = data.posts || [];
// Group posts by year for better organization
const postsByYear = {};
posts.forEach(post => {
const year = new Date(post.date).getFullYear();
if (!postsByYear[year]) {
postsByYear[year] = [];
}
postsByYear[year].push(post);
});
This approach means adding a new blog post is as simple as uploading an HTML file and updating the JSON metadata - no database required!
The Media Directory Strategy
One interesting challenge was handling images and videos. Rather than checking them into git (which would bloat the repository), I implemented a protected /media/ directory in S3 that's explicitly excluded from deployment scripts:
# From deploy.sh - note the media exclusion
aws s3 sync "$PUBLIC_DIR" "s3://$S3_BUCKET" \
--delete \
--exclude "media/*" \ # Never touch media files
--cache-control "public, max-age=3600"
This means media files are uploaded manually and persist across deployments - a simple but effective content management strategy.
Backend Architecture: Serverless and Secure
Infrastructure as Code with Terraform
While the frontend is deliberately simple, the backend infrastructure is defined entirely in Terraform. This isn't just about automation - it's about repeatability and disaster recovery. The entire stack can be destroyed and recreated with confidence:
module "wayne_theworkmans" {
source = "git::git@github.com:wayneworkman/terraform_module_cloudfront.git"
domain_name = "theworkmans.us"
subdomain_name = "wayne"
project = "wayne_theworkmans"
enable_waf = true
waf_mode = "geo_blocking"
geo_include_countries = ["US"]
}
Content Delivery: CloudFront at the Edge
Both the static blog content and the API are served through CloudFront, but with very different configurations. The blog uses CloudFront as a traditional CDN with long cache times for static assets. The API, on the other hand, uses CloudFront primarily for DDoS protection and SSL termination, with minimal caching.
Security Through Simplicity
One thing I learned building this blog is that robust security doesn't need to be complicated. Actually, the opposite is true - by eliminating unnecessary code and endpoints, I've created something that's both more maintainable and more secure. Let me show you what I mean.
CloudFront as the First Line of Defense
All traffic hits CloudFront first, which is brilliant because it means security happens at the edge, before anything reaches my actual infrastructure:
- Geographic restrictions block traffic at the CDN edge (why defend against traffic from countries I'll never serve?)
- AWS Shield Standard provides automatic DDoS protection
- The S3 bucket is never directly exposed - only CloudFront can access it
- SSL certificates are managed automatically through ACM
WAF Rules: Less is More
My WAF setup proves that you don't need dozens of complex rules. Just two simple ones handle 99% of the bad stuff:
- Geo-blocking: US-only traffic. Simple as that.
- Rate limiting: 10 requests per IP address. Stops automation without bothering real users
Everything is denied by default, then I explicitly allow what should get through. Every rule automatically sends metrics to CloudWatch so I can see what's being blocked. You cant secure what you don't measure, right?
API Design: Single Purpose Simplicity
The contact form API is deliberately minimal. It does ONE thing: accepts POST requests to send me messages. That's it. No GET, no OPTIONS, no HEAD requests. If you send anything other than POST, you get an immediate 405 error. This isn't laziness, its security through simplicity - less code means fewer bugs.
By eliminating the OPTIONS preflight dance entirely, I removed about 20% of the API Gateway configuration. No preflight means no preflight vulnerabilities. Sometimes the best code is the code you dont write.
Lambda Security: Paranoid Input Handling
The Lambda function that processes contact form submissions is paranoid about input, and that's a good thing. It strips out:
- All HTML and XML tags (no injection attacks)
- JavaScript protocols and data URIs
- SMTP header injection attempts (those pesky CR/LF characters)
- Control characters that could cause problems
Everything has strict limits too - subject maxes out at 200 characters, messages at 5000. The function validates timestamps, timezones, referrers... basically it trusts nothing. The IAM role for this Lambda? Two permissions only: write logs and read one secret. That's it. Can't leak what you can't access.
Response Strategy: Say Nothing
Here's something interesting - every API response is either "Success" or "Failure". No error codes, no helpful messages about what went wrong, no stack traces. Attackers learn absolutely nothing from the responses. This approach actually reduced my error handling code by like 60% while making things more secure. Win-win.
The Contact Form Flow
When someone submits the contact form, here's what actually happens:
- User fills out the form (those nice wide text boxes at 1200px)
- Completes the reCAPTCHA checkbox
- JavaScript sends everything as JSON to the API
- CloudFront and WAF check the request first
- API Gateway routes to Lambda (POST only, remember)
- Lambda validates everything paranoidly
- Secrets Manager provides the email config
- Email gets sent to my inbox
- User sees either "Success" or a generic error
The Deployment Pipeline
Deployment is refreshingly simple. The deploy.sh script handles everything:
- Syncs the public directory to S3 (excluding media)
- Sets appropriate cache headers (5 minutes for HTML, 24 hours for JS/CSS, 1 year for media)
- Creates a CloudFront invalidation
- Waits for the invalidation to complete
No CI/CD pipeline needed - just ./deploy.sh and the site is live in about 2 minutes.
What This Architecture Doesn't Have (And Why That's Good)
Sometimes security is about what you don't build. This blog gains strength from what's missing:
- No user authentication system to breach
- No database to inject
- No session management to hijack
- No file uploads to exploit
- No complex CORS preflight to misconfigure
- No GET endpoints to cache poison
- No API keys floating around
- No webhooks to abuse
Each thing I didn't add is one less thing that can go wrong. Less code equals less bugs equals less vulnerabilities. It's that simple.
Cost: Almost Nothing
This whole setup runs for less than a dollar a month. Seriously:
- S3 storage: Few cents for a couple MB of HTML
- CloudFront: Free tier covers it
- Lambda: Free tier (1 million requests? I wish I had that traffic)
- API Gateway: Also free tier
- Route 53: 50 cents a month for the hosted zone
The real cost is time spent building it, but honestly that was the fun part.
Monitoring and Observability
Despite being a personal project, the blog has comprehensive monitoring:
- CloudWatch Logs: Lambda execution logs with 7-day retention
- WAF Metrics: Real-time data on blocked requests
- CloudFront Analytics: Cache hit ratios, popular content, geographic distribution
- Cost Explorer: Tagged resources for accurate cost tracking
Lessons Learned
Building this blog taught me a bunch of things:
- Simple is actually powerful - Not every project needs React or whatever the new hotness is
- Inline styles have their place - Yeah it goes against best practices but sometimes pragmatism wins
- Serverless scales to zero beautifully - Perfect for personal projects where traffic is... lets say "modest"
- Security through simplicity works - Less code really does mean less bugs
- The best feature is often the one you don't build - Every line of code is a liability
The whole "security through simplicity" thing really surprised me. By being lazy (or smart?) and not building features, I accidentally made the site more secure. No OPTIONS handler means no OPTIONS vulnerabilities. No database means no SQL injection. No file uploads means... you get the idea.
Wrapping Up
So thats my blog architecture. Is it overengineered for a personal blog? Absolutely. Could I have used WordPress or Ghost or whatever? Sure. But where's the fun in that?
This setup proves you can build something professional and secure without all the complexity of modern web frameworks. Old school HTML/CSS/JavaScript for the frontend, cutting edge serverless for the backend. Sometimes going against conventional wisdom actually works out pretty well.
The irony isn't lost on me that I spent weeks building infrastructure for a blog that gets like 10 visitors a month. But hey, at least those 10 visitors get sub-100ms page loads from CloudFront edge locations worldwide. And my contact form can handle a million messages (though I'd probably cry if that actually happened).
Feel free to view source on any page - its all right there, no minification or webpack bundles to dig through. And if you want to tell me how I'm doing everything wrong, that contact form is waiting for you. It's been tested against everything from friendly messages to automated pentesting attempts. The WAF has blocked some interesting stuff already.
Remember: the best code is often the code you dont write. Unless you're building a blog, apparently. Then you write all the code.