← all posts

August 9, 2026

Building hacksfor.coffee

githubclaudeaws

hacksfor.coffee exists because I wanted a place to write about the things I build that isn’t a GitHub README nobody reads. This post is the recursive case — notes on how the site itself came together, since it’s a project too.

The stack

Astro, built with output: 'static' — there’s no per-request server logic here, just blog posts and project pages that are the same for every visitor, so static output paired with content collections (MDX for blog posts) is the simplest thing that fits. The theme is dark, amber-on-near-black, with JetBrains Mono for headings and IBM Plex Mono for body text, both self-hosted via @fontsource rather than pulled from a third-party font CDN. A faint scanline and vignette overlay gives it a CRT/terminal feel that carries through to the Ko-fi page and brand assets too.

The infrastructure

Terraform (infra/) provisions S3, CloudFront, ACM, and Route 53. The S3 bucket is private — CloudFront reaches it through Origin Access Control rather than S3’s own website-hosting endpoint, so there’s no direct public access to the bucket, only HTTPS through CloudFront.

The more unusual piece is mail forwarding: rather than paying for a third-party inbox service, SES receives mail on the domain’s MX record, writes it to S3, and a small Lambda relays it to a single forwarding address — stripping identifying headers and re-signing with SES’s own DKIM along the way. Adding a new alias is a one-line Terraform variable change, no code involved.

Deploying is one command:

npm run deploy

which builds the site and runs scripts/deploy.sh — sync dist/ to S3, then invalidate CloudFront so a deploy shows up immediately instead of waiting out a cached copy.

What went wrong

Clean URLs broke on the very first deploy. A private S3 bucket behind CloudFront only serves default_root_object for the exact root path (/) — it has none of the “index document” behavior S3’s own website-hosting mode gives you for subdirectories. So /blog/hello-world looked for an object at that literal key, while Astro’s build only ever writes one at blog/hello-world/index.html, and every page except the homepage 404’d.

The fix is a CloudFront Function (infra/cloudfront/url-rewrite.js), wired in as a viewer-request handler that rewrites extension-less paths to their index.html. Genuinely non-obvious, and local astro dev doesn’t catch it at all — Vite’s dev server resolves clean URLs on its own, so the bug only shows up once the site is actually deployed.

Cost / what it actually runs

Rough numbers for personal-blog-scale traffic:

ItemEst. monthly cost
S3 storage< $0.01
CloudFront~$1-3
Route 53 hosted zone$0.50
Route 53 queries< $0.50
ACM certificate$0
SES + Lambda mail forwarding< $0.50
Totalroughly $2-4/month

A budget alert watches whole-account spend and emails a warning at 50%, 80%, and 100% of a monthly cap — alerting only, since AWS Budgets can’t actually stop spend on most of what this stack uses.

Backing it up: git, GitHub, and CI

For a while this whole site — posts, infra code, everything — only existed on one laptop, completely untracked by git. I asked Claude whether that was worth fixing. Once it confirmed there was genuinely zero version history anywhere (not just “no offsite backup” — no git repo at all), the answer was obviously yes.

The mechanics: git init locally — the .gitignore and a GitHub Actions workflow file were already sitting in the repo from earlier scaffolding, just never actually wired up to anything — a clean initial commit, then a new private GitHub repo to push it to. Private rather than public mainly because the Terraform code in infra/ documents the exact shape of the AWS setup, and there’s no reason to publish that layout even though the secrets themselves were already gitignored.

Wiring up the CI workflow to actually deploy was the more interesting part. It already existed and ran on every push to main, but had no AWS credentials to run with — and by design, CI is supposed to get its own narrowly-scoped IAM user rather than reusing the same credentials used for terraform apply. Creating that new IAM user hit an immediate AccessDenied, though: the day-to-day AWS credentials on this machine could deploy the site but couldn’t create other IAM users. Claude wrote a tiny, temporary policy scoped to creating just that one user, I attached it by hand in the IAM console, created the user, attached the real deploy policy (S3 sync + CloudFront invalidation, nothing else) to it, generated an access key, and dropped the values into GitHub Actions secrets.

First push after that went green in under 30 seconds — full build, S3 sync, CloudFront invalidation, no manual deploy step required.

> prompts used
"should I backup the hacksfor.coffee site to a private repo on github?"
"yes, git init, setup the initial ommit and create the private repo"
"done https://github.com/hacksfor-coffee/hacksfor.coffee.git"
"yea, lets add those with a scoped policy"
"i got this error when trying to create the user: AccessDenied ... not authorized to perform: iam:CreateUser ..."
"alright, test it"
"looks green, but there is a deprication warning"

Thanks for reading.


This post was written by Claude Code.