The public presence for tadeumendonca.io — a fully static SPA that serves as the owner's proof-of-engineering: an interactive CV, a portfolio that links to a curated catalog of automations and agentic tools, and a blog. It backs a repositioning to AI Engineer (agentic development / AI-native automations), anchored in SDLC and distributed systems.
Every public URL carries a locale prefix — /pt/… and /en/… — and the path is authoritative, so a
shared link keeps its language regardless of the reader's browser (ADR-0036). The bare root / is the one
deliberate exception: it is prerendered in English as the x-default entry for a JS-less crawler.
Five surfaces:
- Landing (
/) — the storefront; it also hosts the articles list (#artigos). - Interactive CV (
/me) — canonical reference of the owner's experience, and the only CV surface./cv.pdfis the two-page recruiter edition printed from it at build time, in the site's own dark palette. - Portfolio (
/portfolio) — a curated catalog of public repos (automations, agents, MCP servers, AI-native tools) that back the positioning with real code. - Ramp-up (
/ramp-up) — the open plan for the AI-Engineer transition. - Architecture (
/architecture) — how the site is built, linking the ADRs and both public repos.
Long-form writing lives at /blog/:slug, with a per-locale slug (ADR-0037). There is no /blog list
page — it was retired, and /blog redirects to the landing's #artigos.
- Frontend (
apps/fed): React 18 + Vite + TypeScript, Tailwind v3 (no shadcn), no PWA. Content ships in the repo in two shapes — markdown for long-form (articles, ramp-up, architecture) and typed TypeScript for structured data (src/data/profile.tsis the CV). The build prerenders each route in both locales (Playwright) so OG/SEO tags land in the served HTML, and prints/cv.pdfin the same pass. Everything a reader reads is authored in pt-BR and en — chrome, CV and prose alike — and a missing translation is a compile error, not a runtime surprise (ADR-0032, ADR-0036). - Infra (
iac): Terraform for the frontend infra plus one account-wide guardrail — S3 + CloudFront (with a viewer-request URL-rewrite function), custom email via iCloud, the GitHub OIDC deploy roles, and an account-level cost budget (budget.tf) deliberately not scoped to this project's tags, so it catches spend this repo did not create. State in Terraform Cloud, local execution;apply/destroyare pipeline-only.
There is no backend — no API, database, auth, or Lambda. Cost is near-zero / scale-to-zero (static objects on CloudFront); the CI OIDC roles are least-privilege and pinned to the repo's immutable OIDC subject.
Three of the six cost money — and a reader four steps into a setup should not be the one to discover it. What they buy: a site live at your own apex, prerendered in two locales, gated before every merge, and deployed by merging.
| free at this size? | why you need it | |
|---|---|---|
| AWS account | no — see the cost below | S3, CloudFront, Route 53, ACM |
| A registered domain | no — the largest single line in this site's bill | the site serves at an apex you own |
| GitHub account | yes — because this repo is public; Actions minutes are metered on a private one | the repo, and CI is GitHub Actions |
| Terraform Cloud org | yes — this workspace is small enough for the free tier | Terraform state. Execution mode is Local, so TFC holds state and CI runs the plan |
| SonarCloud account | yes — because this repo is public | the quality gate on app |
| Claude Code | no — a paid Anthropic plan | only if you want the loop. See tadeumendonca-skills |
Each cell states its own condition, and none of these vendors is something this file can keep true. They set their own tiers and change them without telling a README, so check the pricing yourself — GitHub, AWS, HashiCorp, SonarCloud, Anthropic — rather than trusting the table above.
The site runs without Claude Code, and the plugin repo is the half you can adopt with no cloud account and no AWS bill at all — it has neither.
- Node ≥ 22 (
enginesinpackage.json) and npm. - Terraform CLI — for
fmt/validateand an inspectionplan. You never runapplylocally: it is pipeline-only. - Playwright's Chromium — installed as its own step (
npx playwright install --with-deps chromium), not pulled in bynpm ci. It is a build dependency rather than a test one: the build prerenders every route in a real browser and prints/cv.pdffrom/en/mein the same pass.
Optional, and it is the one Claude Code setting this repo documents rather than ships. Remote Control
opens a bridge from a running local session, so you can watch it and approve its tool calls from the
Claude mobile app or from claude.ai/code. remoteControlAtStartup starts that bridge automatically
instead of making you run /remote-control each session. It is not the same thing as sending a
session to the cloud: inside a cloud session Remote Control reports itself unavailable, because the local
session it exists to reach is not there.
There are two places to set it, and they are not equivalent. Read out of the shipped CLI (claude --version → 2.1.243) rather than inferred from the key's name, because a setting's name is not a
specification:
| where | what it actually does | |
|---|---|---|
| User preference — the only one that can enable | ~/.claude/settings.json, or /config → Enable Remote Control for all sessions |
Turns it on for every session you run, in every project. It follows the person, not the repo, so each person opts in for themselves and nothing replicates on a fork. This is what I run. |
| Project setting | the committed .claude/settings.json in either repo |
Can only turn it OFF. "remoteControlAtStartup": true here is ignored, and the CLI says so: remoteControlAtStartup: true in project settings ignored — repo-scoped settings cannot enable Remote Control; set it at user scope (/config). false is honoured, and it wins over the user preference — so a repo can refuse the capability for everyone working in it, and cannot grant it to anyone. |
To enable it, at user scope:
{
"remoteControlAtStartup": true
}— or run /config inside Claude Code and set Enable Remote Control for all sessions to true. That
menu's third option, default, removes the key rather than writing false, which is not the same thing:
false is an answer and an absent key is not.
Why the asymmetry is the right way round, and why this repo commits neither value. Enabling is a
property of how a person works; disabling is a property of what a project allows. Committing the
true would hand a session capability to everyone who forks this and to CI without any of them choosing
it — and, per the table, it would not even take effect. Committing false would take effect, and would
remove the choice in the other direction from a forker who wants the capability. So neither is here:
.claude/settings.json carries the permission floor and the plugin wiring, and this setting stays yours.
An organisation that needs the opposite has the managed disableRemoteControl setting, which sits at
policy scope above both.
grep -n remoteControlAtStartup .claude/settings.json returning nothing is that decision, not an
oversight — and apps/fed/src/content/remote-control-scope.test.ts is what keeps it from silently
becoming one.
Single-digit dollars a month, and almost all of it is the name. Registration amortized plus the
Route 53 hosted zone dwarf everything else: publishing is cents, and serving a visitor rounds to zero.
Your TLD sets that line, not this architecture — a .com and a .io are different bills for the
same site. The figure, its measurement date, and
why it is sourced partly from the bill and partly from the registrar's price list are on
/architecture — or, if you have the clone,
architecture.en.md — stated there and not repeated here, so
one number does not go stale on two surfaces. That page also treats it as a measurement with a date
rather than a standing fact, which is the framing the number needs and this table cannot carry.
Read out of the workflows rather than from memory — grep -o 'secrets\.[A-Z_]*' .github/workflows/*.yml
prints twelve lines for seven distinct secrets, and exactly three jobs declare environment: staging
(iac → terraform-plan, deploy → terraform-apply, deploy → deploy-app).
| secret | scope | consumed by | what it gates |
|---|---|---|---|
AWS_FED_OIDC_ROLE_ARN |
environment (staging) |
deploy → deploy-app |
the role that publishes the site; without it nothing reaches S3 or CloudFront |
AWS_INFRA_OIDC_ROLE_ARN |
environment (staging) |
iac → terraform-plan, deploy → terraform-apply |
the role that runs Terraform — no plan on PRs, no apply on merge |
BUDGET_ALERT_EMAIL |
environment (staging) |
iac → terraform-plan, deploy → terraform-apply |
the recipient of the account cost alert, passed to Terraform as a variable |
TFC_API_TOKEN |
repository | iac, deploy |
Terraform's access to its own state |
SONAR_TOKEN |
repository | app → sonarqube-scan |
the quality gate's authentication |
VERSION_BUMP_TOKEN |
repository | deploy → release |
a PAT distinct from GITHUB_TOKEN — mint it fine-grained at contents: write and no wider; a classic token cannot be narrowed that far, and public_repo is the closest it gets. It is the checkout token that pushes the bump commit and tag, and the GH_TOKEN that builds the notes and creates the GitHub Release — which the site's own footer links to, so without it that link 404s |
CLAUDE_CODE_OAUTH_TOKEN |
repository | claude |
@claude on issues and PRs — it silently does not answer without one |
The scope column is what the job can read, not a preference. An environment-scoped secret is
invisible to a job that does not declare that environment, so putting one of the AWS ARNs at repository
scope is not the failure — omitting environment: staging from the job that needs it is. Repository
secrets are readable from environment jobs, which is why TFC_API_TOKEN works in terraform-plan
despite that job declaring staging.
The split has a reason worth keeping: anything naming AWS is environment-scoped; tooling tokens are repository-scoped. That is what stops a token that lints code from reaching anything in the cloud account.
Two of these are easy to miss. CLAUDE_CODE_OAUTH_TOKEN produces no error anywhere — @claude
simply never replies. And the staging environment must exist in repository settings before an
environment-scoped secret can be created at all, which is invisible until it is missing.
Named so you can see what is absent rather than assume it was forgotten — with where it lives today:
- the architecture diagram, with a test holding it against the page's copy. The diagram is on
/architecturetoday.
Two entries left this list rather than being dropped from it. The fork-to-live walkthrough is now
directly below, moved off /architecture because a page that links canonical detail should not also be a
setup guide — and the copy that lived there had gone stale describing renamed workflows, which is the
argument rather than a coincidence. The ADR for the hand-bootstrapped trust root is
ADR-0042, which also narrows
ADR-0014's "no irreversible cloud mutation in the
inner loop" claim to what is actually true.
Roughly an evening, and most of it is waiting on DNS and a certificate — the two steps below that no amount of preparation makes faster. Everything in What you need before you fork is assumed in place.
-
Fork both repos. Read the ADRs first, starting at 0001 — the decisions are the part worth taking, and several of them will not fit your context. If you only want the loop, stop after
tadeumendonca-skills: it installs with no cloud account, no domain and nothing to deploy, and its README is self-contained. -
Register the domain and create its Route 53 hosted zone. Then request an ACM certificate in
us-east-1— CloudFront reads certificates from that region only, wherever the rest of your stack lives — and add its validation CNAMEs to the zone, which is the part that actually makes you wait. Both are read as pre-existing data sources byiac/data.tf; Terraform never creates them, so a missing certificate fails the firstplanrather than being provisioned for you. What this costs you is in What it costs to run — check the renewal price of your TLD, not just the first year. -
Create a Terraform Cloud organization and one workspace, execution mode Local, then point
iac/versions.tfat your names — and the twoTF_WORKSPACEvalues in.github/workflows/, which is where the workspace is actually selected. There are exactly two, and they are not both in the same workflow:iac.yml'sterraform-planjob anddeploy.yml'sterraform-applyjob.grep -rn 'TF_WORKSPACE:' .github/workflows/is the check — change onlyversions.tfand CI still talks to my workspace. Local execution is what the repo uses, not a recommendation: state lives in TFC, butplanandapplyrun in CI, where the credentials are short-lived OIDC roles. Remote mode would keep credentials in the workspace, and then there are two places infrastructure can change from. -
Create the trust root by hand, before the first CI run. Terraform creates the fed deploy role that publishes the site. It does not create the GitHub OIDC provider, the infra role that CI assumes to run Terraform, or that role's
tadeumendonca-iac-deploypolicy — those three are created out of band with the AWS CLI and stay outside Terraform permanently. So before your first CI run the account must already hold five things: the hosted zone and theus-east-1certificate from step 2, plus the provider, the infra role and its policy. Miss any of the last three and the failure is opaque —Not authorized to perform sts:AssumeRoleWithWebIdentity, which reads exactly like the immutable-subject trap in the next paragraph.Two decisions are doing the work here, and neither is setup trivia. Why the trust root is permanently out of band — bootstrap circularity, and the permanent one, that a role able to rewrite its own trust policy has no ceiling — is ADR-0042, which also states its accepted costs: no
plandetects drift on any of it, and the hand path reopens every time that policy changes rather than being a one-time act.docs/iac-deploy-policy.mdis the standing runbook for exactly that, anddocs/iac-deploy-policy.jsonis the desired document to apply. Why the role's trust names an immutable subject —repo:<org>@<org_id>/<repo>@<repo_id>:*, by numeric ID rather than by name, since a name can be transferred and the IDs cannot — is ADR-0015. Its cost is that the safer form is not copy-pasteable: you have to look your own IDs up. -
Wire the GitHub secrets. All seven are in GitHub secrets and variables above, with the scope each one takes and the job that reads it — that table, not a second copy of it here. The one thing to carry forward while you work through it: anything naming AWS is environment-scoped, tooling tokens are repository-scoped, and the
stagingenvironment has to exist in repository settings before an environment secret can be created at all. -
Replace the content and the positioning.
apps/fed/src/content/for the long-form,src/data/profile.tsfor the CV,src/data/catalog.tsfor the portfolio,src/i18n/messages.tsfor the chrome. Every reader-facing module is typed so that a missing translation is a compile error rather than a page that quietly serves the wrong language. -
Merge to
main. The merge is the deploy — there is no promote step and no second environment to catch what the pull request missed. That is the trade the whole architecture makes, and it is only safe because the gates run on the PR.
apps/
fed/ # the static SPA (React + Vite + Tailwind, no PWA)
docs/
adr/ # the decision library — the architecture documentation; start at 0001
iac/ # Terraform for the frontend infra (S3, CloudFront, email, OIDC roles)
# plus one account-wide cost budget, deliberately not scoped to this project
LICENSE # the MIT grant, exact text so GitHub can classify it
NOTICE # the boundary of that grant — the editorial content is reserved
VERSION # single version (numeric SemVer)
MIT on the software, editorial content reserved. The machinery — the SPA, iac/, the build scripts,
the workflows — is yours to fork and ship, and so are the decision records (docs/adr/**,
catalog-ready.md, the IaC policy): a fork that takes the structure without the reasoning takes the
weaker half. The writing and the CV are not: they are published to be read, not relicensed. Reserved is
the default — the MIT side wins only where NOTICE names something.
LICENSE is the MIT grant, kept as the exact MIT text so GitHub can classify it.
NOTICE is the boundary of that grant — what is reserved, and which paths illustrate it.
Read them together; neither is complete alone.
mainis the only branch. Feature/fix branches cut frommain→ PR → merge → automatic deploy to the single environment; the site serves at the apextadeumendonca.io.- Single version (root
VERSION, tagsvX.Y.Z); the deploy'sreleasejob auto-bumps the patch on every push tomain.
Four workflows, named after the top-level directory each one gates — full map, with diagrams, in
.github/workflows/README.md.
app(apps/**) —npm-ci→npm-audit·eslint·tsc·vitest·build-static→playwright·sonarqube-scan, behind a terminalbuild-testaggregator. Its filter also carriesiac/cloudfront-functions/**, which is load-bearing rather than a stray: the CloudFront rewrite function is JS with behaviour, so it is unit-gated here. It stays iniac's filter too — that file is also a Terraform diff, and the two gates prove different things.iac(iac/**) — credential-freecheckovandterraform-fmt, thenterraform-plan(init + validate + plan), the only job holding an AWS token.github(.github/**) —actionlint+ shellcheck.deploy(push tomain) —release→gate→terraform-apply/deploy-app→e2eagainst the live apex.
Each PR workflow runs on every PR and applies its path filter inside the job, then reports whether it skipped, failed part-way, or ran in full — a check that matched nothing must not read like one that passed.
tadeumendonca-skills— Claude Code skills library (plugin + marketplace), whose principles layer this repo consumes.