August 9, 2026
Building hacksfor.coffee
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:
| Item | Est. 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 |
| Total | roughly $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.