How this site is deployed
Terraform across two providers, GitHub Actions with OIDC and no stored cloud keys, and the parts that broke on the way — the architecture story behind the page you are reading.
Click-to-deploy hosting would have taken an afternoon. That was the point of not using it.
This post is the long version of that sentence: why this site exists, what it actually runs on, and how a change to this page travels from a text editor to the CloudFront edge without a single stored cloud credential — with Claude Code doing most of the typing and a pipeline doing all of the deploying.
Why I started building it
I graduated BSIT (Network & Security track) in May 2026, hold three AWS certifications including the DevOps Professional, and I'm looking for my first Cloud/DevOps role. That makes this site a hiring asset, not a personal creative presence — its job is to be the one link that ties my certs, projects, and GitHub together for a recruiter with forty seconds.
But a portfolio makes a claim, and for a DevOps candidate the claim is about how things ship. A portfolio that deploys by clicking a button in Vercel leaves the single most on-message point on the table. So I deliberately chose the more laborious path: the infrastructure is the artifact. The site you are reading is the demo; the public repository — the Terraform, the pipelines, the decision records — is the product.
That framing decided almost everything downstream. The repo is public, so zero secrets in git, ever became a design constraint rather than a cleanup task. Every choice, including the rejected alternatives, is written down in DECISIONS.md and six short ADRs — because "why did you do it that way" is the interview question, and this site exists to answer it before it's asked.
The idea and the backbone
The idea is a document, not an app: a static page with functional chrome — a status bar that shows the deployed commit SHA, a sidebar that knows where you are, an AI assistant that answers from the site's own content. Astro renders all of it to plain static files, which keeps the hosting story simple and the one interactive feature (the chatbot) cleanly separated behind an API path.
Here is the whole production stack:
Everything above the browser is Terraform — two providers (aws and
cloudflare), four modules, state in S3 with native locking. A few of these
boxes carry a decision worth naming:
- The S3 bucket is private
- CloudFront reaches it through Origin Access Control; nobody can bypass the CDN, the cert, or the logs by hitting the bucket directly.
- The ACM certificate lives in us-east-1
- while everything else is in ap-southeast-1 — CloudFront requires it, and it's a classic gotcha worth an ADR of its own.
- Cloudflare stays DNS-only (grey cloud)
-
Proxying would stack two CDNs and, worse, CloudFront's access logs would record
Cloudflare's edge IPs instead of visitors' — silently destroying the analytics. The DNS
records are managed by Terraform's
cloudflareprovider, so the stack is genuinely cross-vendor and a future domain migration is a variable change. - The chatbot is a Lambda Function URL, not API Gateway
- One origin behind the same distribution, room for a WAF later, none of API Gateway's ceremony for a single POST route.
- There is no API key anywhere in this stack
- The Lambda calls Claude Haiku 4.5 through Amazon Bedrock with its own execution role. No Secrets Manager, no rotation, nothing to leak — the "no long-lived credentials" claim covers the chatbot too.
- No RAG
- The assistant's knowledge is a corpus generated from the site's own content at build time, stuffed into a prompt-cached system prompt. Retrieval infrastructure over a handful of documents is résumé-driven development — and a build gate fails if the corpus outgrows the premise.
- Analytics is access logs, S3, and Athena
- No script tag, no cookies, no consent banner. "I query my own access logs" is a better line than "I installed a snippet", and it costs nothing.
- The cost control is code
- An AWS Budget alarm at $10/month, defined in Terraform, alerting over SNS. The whole stack runs at under a dollar a month.
Claude Code and the pipelines
Decisions first, code second
Before any code existed, I spent three days in structured planning sessions with Claude — seven rounds of questioning that settled 45 decisions, each recorded with its reasoning and its rejected alternatives. That record, DECISIONS.md, turned out to be the highest-leverage file in the repo, for two readers at once: future-me, and the agent itself. Claude Code works from the repo's own documents as context, so a decision written down once is a decision it stops relitigating. When I later reversed one (the chatbot's floating bubble, the ASCII portrait), the reversal went on the record too.
The building itself was Claude Code end to end: the Terraform modules, the Astro
components, the workflows, the Lambda handler. I run it with a bench of role-scoped
subagents — a pipeline engineer for the workflows, an IaC engineer for the Terraform, a
read-only security reviewer that audits before anything merges — and the division of labour
is deliberate: the agent writes, automation verifies, and I approve. My
hands stay on exactly two controls: reviewing the pull request (with the
terraform plan posted into it as a comment), and clicking approve on the
GitHub Environment before any apply touches the account.
The pipelines
Four workflows, path-filtered so a typo fix in a blog post never re-plans the infrastructure:
The security model is the part I'd walk an interviewer through:
- No long-lived AWS keys exist — anywhere
-
Each credentialed job assumes its own least-privilege IAM role over GitHub's OIDC
provider. The site role can write one bucket and invalidate one distribution; the
Lambda role can update one function's code and nothing else; the plan role is
read-only; the apply role trusts only the
productionEnvironment's subject claim, so the human approval is enforced by IAM, not by convention. - Trust policies pin GitHub's immutable owner and repository IDs
- So a renamed or recreated repo can't inherit the roles.
- Every third-party action is pinned to a full commit SHA
- A tag is a movable pointer, and these jobs hold credentials to a live AWS account.
permissions: {}at the workflow level- Each job opts into exactly what it needs. The default is nothing.
- The gates are the thesis, not decoration
- Gitleaks runs against full history with a custom rule that fails the build if my phone number ever lands in the tree (the public résumé ships without it). Checkov findings are triaged in-line, never silently suppressed. A public infra repo with secret scanning in CI proves the exact claim the résumé makes.
What broke, because that's the honest half
A green pipeline is not proof. The chatbot's first deploy failed its smoke test, and I
misread the failure as a Bedrock verification delay — it was also two real faults in the
CloudFront-to-Lambda hop: a missing lambda:InvokeFunction permission beside
InvokeFunctionUrl, and the SigV4 payload-hash requirement on OAC-signed POSTs.
The smoke test now hashes its payload exactly like the browser does, so it exercises the
real path.
Lesson kept: a check that cannot pass for the right reason teaches you to explain away its failure.
And the first CI apply after that fix failed during plan — the apply role,
written back when the stack was just S3 and CloudFront, had never been extended for Lambda
and DynamoDB. It stayed invisible because those resources had been applied from my laptop,
so the role was never exercised.
Lesson kept: least-privilege on a Terraform role isn't "what may this change" but "what may this see" — and adding a service to the stack means extending the role in the same commit.
Both post-mortems are in the decision record, next to the decisions that caused them. The status bar at the bottom of this page shows the commit SHA this site was deployed from, linked to that commit on GitHub. That one detail is the whole post in miniature: the site knows how it got here, and so do I.