../projects

build Explanation

How This Site Is Built: Architecture and Decisions

2026-07-18

AstroTypeScriptTerraformAWS S3CloudFrontGitHub ActionsOIDC

This page explains the very site you’re reading: what it is made of and why each piece was chosen. It is the why. If you want the reproducible steps to build one yourself, that lives in a separate field guide (see the note at the end).

The goals

The site had to be fast, so it is static output with minimal JS, delivered from a CDN. It had to be reproducible, so every piece of infrastructure lives in version control and I can tear the whole thing down and rebuild it from nothing. And it had to be secure by default: no public S3 bucket, no long-lived AWS credentials in CI, and no code that ships without a security scan.

Architecture

GitHub push (main)


GitHub Actions ── builds Astro (static) ──┐
      │  assumes IAM role via OIDC         │
      ▼                                     ▼
   aws s3 sync  ───────────────►  S3 bucket (private)
      │                                     │  origin
      └── CloudFront invalidation           ▼
                                     CloudFront (CDN + HTTPS)

                            ACM cert ◄──────┤
                            Route 53 DNS ◄──┘
LayerServiceWhy
BuildAstro (static)Near-zero JS, content-first
StorageS3 (private)Cheap, durable origin; locked behind Origin Access Control
DeliveryCloudFrontGlobal CDN, HTTPS, caching, custom domain
TLSACMFree, auto-renewing certificates
DNSRoute 53Apex + www records pointing at CloudFront
CI/CDGitHub ActionsBuild + deploy on every push to main
SecuritySemgrep (SAST)Scans every push and PR; the deploy job won’t run until it passes
AuthOIDC → IAM roleShort-lived credentials, no secrets stored in GitHub
IaCTerraformThe whole stack, reproducible and reviewable

Why these choices

The quickest path to a static site on AWS is to flip on S3 static website hosting and point DNS straight at the bucket. I skipped it, because that leaves the bucket world-readable and gives up edge caching and TLS control. The bucket here has no public access at all, and CloudFront reaches it through an Origin Access Control (OAC), so the only way to read the files is through the CDN, where I control caching and TLS.

For credentials, GitHub Actions federates into AWS and assumes a narrowly-scoped IAM role for the duration of a single job. The alternative, a stored AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY pair, is a long-lived credential that sits in repo secrets waiting to leak and has to be rotated by hand. OIDC trades that for a token that expires when the job does, and a role scoped to exactly two actions: sync the bucket and invalidate the cache.

The S3 bucket, CloudFront distribution, OAC, ACM certificate, Route 53 records, and the CI IAM role are all declared in infra/, so terraform apply builds the stack and terraform destroy removes it.

Semgrep runs SAST (secrets, JavaScript, and default rulesets) as its own job, and the deploy job depends on it, so a finding blocks the release instead of turning up in a report after the site is already live.

How the deploy runs

This is a description of what the automation does on each push, not steps for you to follow. For the do-it-yourself version, see the field guide linked below.

  1. I push to main.
  2. A Semgrep SAST job scans the source first (secrets, JavaScript, and default rulesets). The deploy job depends on it, so nothing ships if it flags a problem.
  3. GitHub Actions checks out, runs npm ci and npm run build.
  4. The job requests an OIDC token and assumes the deploy IAM role.
  5. aws s3 sync ./dist s3://<bucket> --delete uploads only what changed.
  6. A CloudFront invalidation (/*) flushes the edge cache.
  7. The new version is live worldwide in under a minute.

For a deliberately heavier take on this same delivery path, one that makes every moving part explicit and operable, see Self-Hosted Delivery Pipeline.

For information on how to set up a similar pipeline for yourself, please check out my how-to field guide here!