TL;DR
This site is deliberately boring infrastructure for a reason: Hugo generates static HTML with the PaperMod theme. Terraform manages AWS (S3, CloudFront, Route53, ACM). GitHub Actions and self-hosted k3s runners deploy on every push to main. An AI pipeline (Bedrock + a Python script) drafts articles into Hugo page bundles and opens PRs for review. There’s no dynamic backend, no database, no server to maintain. The AWS bill is ~$30/month. This post is a tour of the machine that prints the other posts.
Motivation: boring is good
When I started this blog in 2024, I had some non-negotiable constraints:
- No vendor lock-in. I wanted to own the content and the build pipeline. Not Medium, not Substack, not a hosted CMS.
- Cheap to run. I’m on a homelab budget; $100/month would be better, but $30/month is already pushing it.
- Low operational overhead. No app server crashes, no database to back up, no user authentication to manage.
- Easy to iterate on. I wanted to ship a new post in under 10 minutes — write → commit → deployed.
Static HTML checks all of those boxes. It’s also wildly unfashionable, which is precisely the point. The tech industry convinced everyone they need a React frontend and a Node.js backend for a blog, which is absurd. A bunch of .html files served by a CDN is still the right answer.
The pipeline: from idea to production
Here’s what happens when I push code to the main branch:
push to main (hugo/*)
↓
GitHub Actions → k3s self-hosted runner
↓
Hugo build (--minify --environment production --buildFuture)
↓
S3 sync (two passes: HTML with 1hr cache, assets with 1yr immutable)
↓
CloudFront invalidation (/* — clears all edges)
↓
Live at https://blog.zolty.systems
The runner is self-hosted on my k3s cluster (GitHub Actions Runner Controller via ARC). It checks out the repo, runs Hugo, and syncs the output to S3. No third-party build service touching my repo.
The architecture
Static site generation: Hugo + PaperMod
Hugo is a Go-based static site generator that turns Markdown into HTML. Fast, dependency-light, no Node.js. The theme is PaperMod, a minimal, fast, and keyboard-friendly design. It ships with dark mode, responsive typography, and semantic HTML without the bloat.
Content is organized as page bundles: each post lives in a directory like hugo/content/posts/2026-06-19-how-this-blog-is-built/, with index.md and co-located images in the same folder. Hugo keeps everything together; no separate image management.
hugo/content/posts/
├── 2026-05-17-vault-behind-authentik/
│ ├── index.md
│ └── vault-architecture.png
├── 2026-06-18-comfyui-blog-image-pipeline/
│ ├── index.md
│ ├── generation-workflow.png
│ └── output-example.jpg
└── 2026-06-19-how-this-blog-is-built/
└── index.md
Hugo’s front matter is YAML:
---
title: "Post Title"
date: 2026-06-19T11:00:00-04:00
draft: false
author: "zolty"
description: "150–160 character summary for search engines"
tags: ["tag1", "tag2"]
categories: ["Projects"]
cover:
image: "/images/covers/projects.svg"
ShowToc: true
TocOpen: false
---
The --buildFuture flag during CI means posts with future dates don’t show up in production until their date arrives. I write posts weeks in advance and schedule them; the daily cron deploy publishes them on schedule.
S3 + CloudFront: content delivery
S3 holds the static HTML, CSS, JS, images — the entire compiled site. CloudFront is the CDN in front, with edge locations worldwide. Route53 points blog.zolty.systems to the CloudFront distribution.
The deployment strategy is a two-pass S3 sync to optimize cache headers:
Pass 1: HTML, XML, JSON, TXT
aws s3 sync hugo/public/ s3://bucket/ \
--include "*.html" --include "*.xml" --include "*.json" --include "*.txt" \
--cache-control "public, max-age=3600" \
--delete
Short cache (1 hour) on mutable content. If I deploy a typo fix, it propagates within an hour.
Pass 2: Everything else
aws s3 sync hugo/public/ s3://bucket/ \
--exclude "*.html" --exclude "*.xml" --exclude "*.json" --exclude "*.txt" \
--cache-control "public, max-age=31536000, immutable" \
--delete
Long cache (1 year, immutable) on CSS, JS, images. These files are fingerprinted by Hugo’s build process (styles.abc123.css), so changing an image actually changes the filename.
After the sync, CloudFront is invalidated with /* to purge all edges. It’s not instant — propagation takes a few minutes globally — but it’s reliable.
Terraform: IaC for AWS
All AWS infrastructure lives in terraform/. S3 buckets, CloudFront distributions, Route53 records, ACM TLS certificates, IAM users and access keys. Nothing is created by hand. When a change lands on main, the GitHub Actions Terraform workflow runs terraform apply automatically.
Example (simplified):
resource "aws_s3_bucket" "blog" {
bucket = "zolty-blog-content"
}
resource "aws_s3_bucket_versioning" "blog" {
bucket = aws_s3_bucket.blog.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_cloudfront_distribution" "blog" {
origin {
domain_name = aws_s3_bucket.blog.bucket_regional_domain_name
origin_id = "s3-origin"
}
# ... distribution config ...
}
resource "aws_route53_record" "blog" {
zone_id = var.zone_id
name = "blog.zolty.systems"
type = "A"
alias {
name = aws_cloudfront_distribution.blog.domain_name
zone_id = aws_cloudfront_distribution.blog.hosted_zone_id
evaluate_target_health = false
}
}
Terraform state is stored in S3 with encryption and versioning enabled. This means I can run terraform plan before every apply and see what’s about to change.
Content generation: Bedrock drafts articles
I have a Python script (content-gen/scripts/generate_article.py) that talks to AWS Bedrock (Claude 3.5 Sonnet). I trigger it via GitHub Actions workflow or run it locally. You give it a topic:
python generate_article.py --topic "How static sites won the web"
The script calls Bedrock with a system prompt that enforces:
- Pseudonym “zolty” only (no PII)
- zolty.systems domain only
- Blog voice and structure (TL;DR, sections, honest caveats, lessons)
- Hugo front matter syntax
- Page bundle layout
Bedrock generates a complete article into hugo/content/posts/YYYY-MM-DD-slug/index.md, complete with front matter. The script opens a GitHub PR with the draft. Images are a separate step — I generate those locally with ComfyUI, both the prompt-to-CDN pipeline for covers and background removal and batch resources for everything else.
I review, edit, fix any hallucinations (the AI sometimes misremembers config), and merge when ready. The deploy workflow picks it up immediately.
This cuts my “from blank page to publishable first draft” time from 2–3 hours to maybe 45 minutes of editing. The AI doesn’t have to be perfect; it just has to be a solid starting point.
Comments: Giscus (no backend)
There’s no comment server. Comments live in GitHub Discussions, pulled in by Giscus. Each post has a linked discussion thread. Readers sign in with GitHub to comment. No spam, no moderation backend, no database.
<script src="https://giscus.app/client.js"
data-repo="zolty/zolty-blog"
data-repo-id="..."
data-category="Post Comments"
data-mapping="pathname"
data-reactions-enabled="1"
data-emit-metadata="0"
async>
</script>
It’s embedded in the PaperMod footer partial. Works great.
Monetization: affiliate links and Google Ads
The blog uses Google Ads (in-article placements via a custom {{< ad >}} shortcode) and Amazon Associates affiliate links. The affiliate tag is configured in hugo.toml and used in a shortcode:
{{< amzn search="Product Name" >}}link text{{< /amzn >}}
It generates an Amazon search URL with my affiliate code. I only add links where products are naturally mentioned — not forced, not everywhere. Disclosure is site-wide via the footer.
Google Ads only render in production builds (hugo.Environment == "production"). Local dev and preview builds don’t show ads.
The honest caveats
Cache invalidation is the classic footgun
Phil Karlton said “there are only two hard things in computer science: cache invalidation and naming things.” I do a /* CloudFront invalidation on every deploy, which works but isn’t free. Each invalidation request costs a tiny amount (fractions of a cent), but they add up if I deploy constantly. For a blog, it’s fine. For a high-traffic site, you’d want a smarter strategy — invalidate only changed paths.
Static sites mean no dynamic features
No comments built-in (Giscus works, but it’s GitHub-backed). No member paywall. No server-side analytics beyond CloudFront logs. No A/B testing. If I needed any of those, I’d have to bolt on a backend.
AI drafts always need editing
The Bedrock pipeline saves time, but the output still needs human review. It hallucinates commands, gets dates wrong, sometimes invents config that doesn’t exist. It’s a really good first draft, not finished copy.
The build is reproducible but not instant
Hugo rebuilds the entire site on every deploy. For my volume (30–40 posts), it takes ~2 seconds. At scale (thousands of posts), you’d need incremental builds. But for a single-author blog, it’s fine.
Lessons
- Boring infrastructure is feature-complete infrastructure. Static HTML, object storage, a CDN, and Terraform will outlive any trendy meta-framework.
- Self-hosted CI is worth the setup. GitHub Actions self-hosted runners on my k3s cluster run for free (I already have the cluster). No vendor bill for CI.
- Terraform makes AWS reproducible. Before Terraform, I was afraid to touch AWS config. Now it’s version-controlled and reviewable like any other code.
- Giscus + GitHub Discussions is the right comment layer for a technical blog. No spam, no backend, no moderation logic. Comments live where code lives.
- AI drafting doesn’t replace writing, but it does replace the blank page. 50 minutes of editing a solid draft beats 2 hours of writing from zero.
Don’t have a homelab or the patience for self-hosted runners? Static site hosting is commodity now — Vercel, Netlify, and DigitalOcean App Platform all handle Hugo deploys automatically. Push to GitHub, they build and deploy. The tradeoff is vendor lock-in (different APIs, different deployments), but the simplicity is real. And if you outgrow static hosting, migrating 30 Markdown files is trivial.