$ duy_

$ 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.

build provenance
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
architecture — this site

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 page you are reading came down the top row and is being served by the middle row. The footer names the exact commit and workflow run.

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:

  1. Content-hashed assets first, with max-age=31536000, immutable. They go up before any HTML can reference them.
  2. HTML next, with max-age=0, must-revalidate and --delete, so a deleted page really disappears. _astro/* is excluded, so this pass can never touch an asset.
  3. 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:

headervalue
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 itemper monthwhy
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, not about/index.html, and a CloudFront Function appends .html at 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