The constraint
A portfolio site for a cloud-infrastructure role should itself be the artifact. That means the stack has to be defensible on its own terms — not because it’s clever, but because the trade-offs are right for the problem. The problem here is small: a handful of static pages, single author, no forms, no auth, no database, single-digit-RPS traffic at best. That sizing rules out a server. It rules in content-addressable storage behind a CDN, which is the boring, reliable, sub-$2-per-month answer. Total run-rate sits at roughly $18 per year, and the line item that dominates is Route 53’s hosted zone plus the domain registration — everything else is fractional cents.
The architecture
Four AWS services do the work. S3 holds the built artifacts in a private bucket — no website-hosting mode, no public ACLs, no bucket-policy hack. CloudFront sits in front with an Origin Access Control that signs each origin request with SigV4, so the bucket only answers to this one distribution. A Response Headers Policy attached to the default cache behavior emits HSTS preload, a strict default-src 'self' CSP, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, and a Permissions-Policy that disables camera/microphone/geolocation/interest-cohort by default. Route 53 holds the apex zone with alias records pointing at the distribution. The ACM certificate lives in us-east-1 — not a regional preference; CloudFront edges only accept certificates from that region.
The deploy pipeline
The differentiator isn’t the static stack — it’s that no long-lived AWS credentials live anywhere in GitHub. Pushes to main trigger a workflow that exchanges GitHub’s OIDC token for short-lived AWS credentials via sts:AssumeRoleWithWebIdentity. The trust policy on the deploy role pins to repo:andruxap/resume-website:ref:refs/heads/main and the production environment, so only the right repo, the right branch, and a workflow run inside that GitHub environment can assume it. Permissions on the role are scoped to one S3 bucket and one CloudFront distribution — nothing else in the account is reachable, even if the role is misused.
The sync itself is two passes by design. Hashed assets under _astro/ get Cache-Control: public, max-age=31536000, immutable — they never need revalidation because the filename changes when the bytes change. HTML, the sitemap, and robots.txt get Cache-Control: public, max-age=300, must-revalidate — they’re the routes whose meaning changes between deploys. After the sync, the workflow creates a CloudFront invalidation over /* and waits for completion before running curl smoke tests against /, /about/, and a known-404 path. End-to-end, push-to-live takes about three minutes.
What I’d do differently at scale
For a higher-traffic or multi-author version of this stack, three changes earn their keep. Multi-region origin failover matters once the cost of an S3 outage exceeds the cost of cross-region replication and the slightly fussier IaC — CloudFront is already global, but the origin isn’t. WAF earns its base fee the moment there’s anything to protect: a login surface, an authenticated API, a form-driven workflow. And build-time content sourced from a headless CMS (Contentful, Sanity, Astro DB) decouples editing from the git workflow — useful when the people writing copy aren’t the people writing infrastructure.
I’d also automate the infrastructure deploy itself. Right now terraform apply runs from my laptop; for a team or for a more defensible change history, that should be a workflow that opens a PR with terraform plan as a comment, gates the apply behind environment-protected reviewers, and runs from inside an OIDC-authenticated session — same pattern as the site deploy, applied to the stack underneath it.
Links
- Repo: github.com/andruxap/resume-website
- Live: andruxa.dev