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:

Production infrastructure Cloudflare DNS points at CloudFront, which routes the default path to a private S3 bucket and /api/* to a Lambda Function URL. The Lambda calls Amazon Bedrock, a DynamoDB state table, and reads its corpus from S3. Access logs go to S3 for Athena; a budget alarm notifies over SNS. DNS Cloudflare DNS DNS-only · in Terraform CloudFront HTTPS · ACM cert in us-east-1 default (OAC) /api/* (OAC, SigV4) S3 bucket private · Astro output Lambda Function URL auth = AWS_IAM Amazon Bedrock Claude Haiku 4.5 global inference profile DynamoDB salted-hash limits + cache rows expire by TTL S3 corpus.json built from the site read on cold start Alongside access logs → S3, 90-day lifecycle → Athena on demand budget alarm ($10, in Terraform) → SNS → email
Everything below the browser is Terraform — two providers, four modules, state in S3.

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 cloudflare provider, 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:

CI/CD pipeline Every push and pull request runs ci.yml, which holds no credentials: gitleaks over full history, a resume PDF text scan, the Astro build, a link check and an advisory Lighthouse run. On merge to main, path filters route the change to one of three workflows — site.yml, infra.yml or lambda.yml — each assuming its own least-privilege role over OIDC. infra.yml additionally waits for a human to approve the production environment before applying. push / pull request ci.yml — every push and PR, no credentials gitleaks, full history + phone-number rule · résumé PDF text scan astro build · link check (lychee) Lighthouse — advisory, never blocking merge to main — path filters route it site/** site.yml build → sync to S3 invalidate everything not content-hashed verify apex returns 200 infra/** infra.yml fmt · validate · tflint checkov plan on PR, as a comment approval gate → apply lambda/** lambda.yml package UpdateFunctionCode smoke-test /api/chat through CloudFront OIDC — one least-privilege role per credentialed job, no stored AWS keys
Three workflows, one least-privilege role each, and no long-lived AWS keys anywhere.

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 production Environment'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.

Ask about Luis

AI assistant — it can be wrong.