$ terraform show — this site
Infra
A CV can claim "CI/CD to AWS". This page is the claim, running. Everything below describes the site you are reading right now — the same bucket, the same distribution, the same workflow — and every number is one you can check yourself from a terminal.
- source
- local build — no commit stamped
- shipped by
- npm run build, on a laptop
- built at
- 26 Aug 2026, 08:40 UTC
- origin
- private S3 bucket, reachable only through CloudFront
Top band, commit to production: a push to main triggers GitHub Actions, which runs astro check and astro build, exchanges an OIDC token with STS for short-lived credentials, then runs three aws s3 sync passes and a CloudFront invalidation. Middle band, what a visitor hits: the browser resolves Route 53 alias records, reaches a CloudFront edge over TLS 1.2 with HTTP/2 and HTTP/3, a CloudFront Function rewrites www to apex and clean URLs to .html, and CloudFront fetches from a private S3 bucket using origin access control with SigV4. Bottom band, the guarantees that buys: no public object, no long-lived keys, no third-party origin, 403 mapped to a real 404, cache headers set at upload, and about fifty-five cents a month.
The deploy, exactly
A push to main is the only way anything changes. The workflow type-checks,
builds, then exchanges a GitHub OIDC token for a fifteen-minute AWS session — there is no
access key anywhere in the repository or in the account. Uploads happen in three passes,
because cache headers are a property of the object, not of the CDN:
- Content-hashed assets first, with
max-age=31536000, immutable. They go up before any HTML can reference them. - HTML next, with
max-age=0, must-revalidateand--delete, so a deleted page really disappears._astro/*is excluded, so this pass can never touch an asset. - A pruning pass over assets, once nothing points at the old ones any more.
Then a single /* invalidation — which counts as one path against the thousand
free ones each month — and the job waits for it to complete before declaring success. The
last step is a smoke test that asserts a missing URL answers 404, not
200 and not 403.
Why the origin is private
The obvious way to host a static site on S3 is the website endpoint with a public bucket
policy. It is also the way to end up with an origin anyone can fetch directly, bypassing
every header and control the CDN adds. This bucket blocks public access on all four
settings, has ACLs disabled entirely, and its policy grants exactly one principal —
cloudfront.amazonaws.com — and only when the request carries the
AWS:SourceArn of this one distribution. Anything else, including plain HTTP,
is denied.
That buys the security property and costs one subtlety: a private REST origin answers a
missing key with 403 AccessDenied, not 404, because listing is not
granted. Both codes are therefore mapped to /404.html with a
404 response code, which is why the smoke test above exists at all.
What CloudFront actually sends
Not a recommendation list — the live policy, verbatim:
| header | value |
|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload |
Content-Security-Policy | default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=() |
Cross-Origin-Opener-Policy | same-origin |
script-src 'self' is the one worth pointing at. It holds because the site ships
exactly one script file, /theme.js, and zero inline JavaScript — the theme
toggle is a delegated listener rather than a per-component
<script> block. 'unsafe-inline' survives only in
style-src, where Astro's critical CSS and Shiki's per-token styles need it.
The bill
| line item | per month | why |
|---|---|---|
| Route 53 hosted zone | $0.50 | flat, per zone, per month |
| Route 53 queries | $0.00 | alias records to CloudFront are not billed |
| CloudFront | $0.00 | 1 TB out and 10M requests a month are always free |
| CloudFront invalidations | $0.00 | '/*' is one path; 1,000 free a month |
| S3 storage | < $0.01 | the whole site is well under a megabyte |
| S3 requests | < $0.01 | only on a cache miss at the edge |
| ACM certificate | $0.00 | public certificates are free |
| GitHub Actions | $0.00 | public repository |
| total | ≈ $0.55 | the hosted zone is essentially the whole bill |
Domain renewal is separate and paid to the registrar. The distribution runs on
PriceClass_200 rather than the cheaper-sounding PriceClass_100:
the free tier is identical for both, and PriceClass_100 has no edge locations
in Asia, which would route visitors here through North America or Europe. Cheaper on paper,
slower in practice, and not actually cheaper.
Trade-offs I made on purpose
- SSE-S3, not SSE-KMS
- KMS bills per request, and CloudFront reads this bucket on every cache miss. For public content that nobody needs a separate key policy over, the KMS charge buys nothing.
- Access logging off by default
- It is the one line item that grows with traffic, and it stores requester IPs. It is a single variable away if I ever need it; until then the site keeps no logs about you.
- No web fonts, no analytics, no third-party anything
- Every byte comes from one origin. That is what makes the CSP above honest rather than aspirational, and it means the page renders before a font swap can flash.
build.format: 'file'plus a CloudFront Function-
Astro emits
about.html, notabout/index.html, and a CloudFront Function appends.htmlat the edge. Clean URLs without a directory-index shim, and the same function handles the www redirect in the same microsecond of compute. - Terraform state stays local for now
- The S3 backend block is written and commented out. With one operator and one environment, an encrypted local state file plus version control is honest about the actual risk; the backend goes live the moment a second person touches it.
Don't take my word for it
Every claim on this page is one command away:
# security headers, straight from the edge
curl -sI https://<this-domain>/ | grep -Ei 'strict-transport|content-security|x-frame'
# a missing page must be a real 404, not a 403 or a soft 200
curl -s -o /dev/null -w '%{http_code}\n' https://<this-domain>/definitely-not-a-page
# and the origin must refuse to talk to you directly
curl -s -o /dev/null -w '%{http_code}\n' https://<bucket>.s3.<region>.amazonaws.com/index.html
# expect 403