Problem
The console-first hosting topology proved that four independently deployable surfaces - portfolio, blog, quiz, news-feed - belong on one subdomain / one distribution / one private bucket. The content pipeline proved that those surfaces share one build-time SQLite artifact.
What was still missing was the delivery platform:
- Provisioning that topology for TEST / STAGE / PROD without click-ops drift
- A CI contract that ingests once, builds four apps in parallel, and deploys per domain - not one merged bundle pretending to be four hosts
- Environment semantics that match how a lead ships software: preview continuously, test against fixtures, rehearse releases on gated stage, promote to public prod under human approval
- Deploy credentials that are short-lived and environment-scoped - no long-lived AWS access keys in GitHub secrets
A personal platform is still a platform. The release path had to read like something I would sign off for a team.
Solution
I treated infrastructure and CI as one architecture: Terraform owns the edge stacks; GitHub Actions owns the promotion path.
Reusable modules under infrastructure/aws/modules/ encode the static-site contract (private S3 + OAC, ACM in us-east-1, CloudFront + viewer-request Function, Route 53 A/AAAA aliases) and a GitHub OIDC deploy role. Each environment folder (envs/test, envs/stage, envs/prod) wires four site stacks - same shape, different hostnames and auth posture.
Pipelines then consume those stacks: DEV continuous-previews on GitHub Pages; TEST / STAGE / PROD sync each dist to its bucket and invalidate its distribution via sts:AssumeRoleWithWebIdentity.
Architecture
┌──────────── trunk: main ────────────┐
│ PRs → ci.yaml (quality + changeset) │
└─────────────────┬────────────────────┘
│
push to main │ Changesets version publish
▼ ▼
deploy-dev.yaml release.yaml
live content live content
merge 4 apps → stage.* (Basic Auth) ──► human gate
GitHub Pages ──► paulserban.eu / blog.* / quiz.* / news-feed.*
│
workflow_dispatch
▼
deploy-test.yaml
fixture content (content/test)
test.* (Basic Auth) → S3 + CloudFront x 4
Provision (manual terraform apply per env):
modules/static-site x 4 + modules/github-oidc-deploy-role
(+ assets-cdn on prod)
│
▼
Route 53 → CloudFront (TLS, OAC, Function) → private S3
| Concern | Ownership |
|---|---|
| Topology | Terraform static-site module - S3, ACM, OAC, CloudFront, Function, Route 53 |
| Deploy identity | Terraform github-oidc-deploy-role - trust scoped to repo:…:environment:{env} |
| Build contract | Reusable _build-site.yaml - ingest → parallel builds → optional merge |
| Promotion | deploy-dev / deploy-test / release - different triggers, content sources, and gates |
| Secrets model | GitHub Environment variables for bucket/distribution IDs + role ARN; OIDC token at runtime; no AWS keys |
Approach
As architect and lead implementer, I owned the seams between provisioning, build, and promotion so environment behaviour is a deliberate contract - not tribal workflow knowledge.
Environment contract first
| Env | Trigger | Content | Hosting | Gate |
|---|---|---|---|---|
| DEV | Every main push (+ successful news-sync) | Live private content repo | GitHub Pages merged preview (/home, /blog, /quiz, /news) | Continuous |
| TEST | workflow_dispatch | Committed fixtures (content/test) | test.{,blog.,quiz.,news-feed.}paulserban.eu via S3 + CloudFront, Basic Auth | Manual / automation |
| STAGE | Changesets version publish | Live content | stage.* subdomains, Basic Auth | Release rehearsal |
| PROD | After stage success | Live content, prod absolute URLs | Public apex + subdomains | GitHub Environment production-approval |
- DEV answers "does the platform still build together?"
- TEST answers "do deploy + edge behaviour work against known fixtures?"
- STAGE answers "does unpublished/live content look right before public?"
- PROD answers "ship."
That split is intentional platform design: preview velocity, regression isolation, release rehearsal, and public blast radius are different jobs - they get different environments.
Terraform as transcription of a proven topology
The console bootstrap discovered the real AWS contract. Terraform does not reinvent it - it codifies it:
static-sitemodule - private bucket (public access blocked, owner-enforced, SSE-S3, versioning), ACM cert inus-east-1with DNS validation into the shared hosted zone, CloudFront + OAC, security headers policy, viewer-request Function (directory →index.html, optional Basic Auth, optional path redirects), Route 53 A/AAAA aliases- Quiz SPA variant -
not_found_response_page = "/index.html"so client routes survive refresh; SSG sites soft-map to/404.html - Prod-only extras - apex redirects (
/blog→blog.paulserban.eu,/assets→ assets CDN) and a separateassets-cdnmodule so content media stays off the app deploy path github-oidc-deploy-role- one IAM OIDC provider per account (created once in TEST); each env role trusts only that repo + that GitHub Environment, with permissions limited to List/Put/Delete on the four buckets andCreateInvalidationon the four distributions
Apply stays manual per env for now (local state, deliberate solo-maintainer trade-off). CI never runs terraform apply - it assumes the stack exists and deploys into it. That keeps provision and promote as separate change windows.
Pipeline shape: ingest once, fan out, deploy per domain
_build-site.yamlis the reusable build DAG - migrate → sync/ingest (live or test) → uploadcontent.db+ quiz JSON → four parallel site builds with env-specificASTRO_SITE/ cross-app URLs → optional merged bundle for Pages- AWS deploys set
emit_merged_bundle: false- each surface is a root-relative app on its own hostname; merging is a DEV/Pages concern only - Parallel
deploy-*jobs per surface: download artifact → assume OIDC role →aws s3 sync --delete→ CloudFront invalidation/* - Release path - Changesets tags versions → build/deploy STAGE → single
production-approvalenvironment gate → rebuild with prod URLs → deploy PROD. Prod rebuilds with production origins baked in (absolute cross-site URLs); stage never accidentally shipsstage.*links to the public internet
Operational design (lead-engineer scale)
- Trunk-based - one long-lived
main; short-lived feature branches; nodevelop/release/*theatre for a solo platform that still wants team-grade promotion - GitHub Environments as the security boundary - OIDC
subclaims bind deploy roles toenvironment:test|stage|production; approval lives on a dedicatedproduction-approvalenv so reviewers are not prompted four times (once per app) - Basic Auth on non-prod AWS edges - CloudFront Function enforces credentials on TEST/STAGE so unpublished and fixture content stay off the public web without a separate VPN story
- Named trade-offs - local Terraform state until a shared backend is justified; prod rebuild after approval (URL baking) instead of byte-identical artifact promotion across stage→prod
Stack
| Layer | Choices |
|---|---|
| IaC | Terraform ≥ 1.6, reusable modules, per-env roots |
| Edge | CloudFront, OAC, ACM (us-east-1), Functions (cloudfront-js-2.0) |
| Origin / DNS | Private S3 + Route 53 aliases on paulserban.eu |
| CI | GitHub Actions, reusable workflow_call, Changesets |
| Auth to AWS | GitHub OIDC → IAM role; no long-lived access keys |
| Preview | GitHub Pages (merged multi-app bundle) |
Design decisions that mattered
- Modules over copy-paste env trees. Four surfaces x three AWS envs is twelve stacks; the module is the unit of correctness, the env root is wiring.
- OIDC over access keys. Short-lived credentials scoped to environment remove a whole class of secret rotation and leak risk.
- Separate content sources for TEST vs STAGE/PROD. Fixture content makes automation deterministic; live content makes release rehearsal honest.
- DEV on Pages, AWS for the real domain matrix. Continuous preview does not need four CloudFront distributions; promotion and edge behaviour do.
- Human gate once, deploy four times. Approval is a release decision; parallel deploys are an implementation detail.
- Trade-off named up front. Local state and manual apply keep the first IaC slice operable for one maintainer; migrate to S3+DynamoDB backend when CI or a second operator needs shared apply.
What shipped
- Terraform modules:
static-site,github-oidc-deploy-role,assets-cdn - Applied env roots for test, stage, and prod (four sites each; prod + assets CDN)
- Workflows:
ci.yaml,deploy-dev.yaml,deploy-test.yaml,release.yaml, reusable_build-site.yaml - Keyless deploys to S3 + CloudFront invalidation via environment-scoped OIDC roles
- DEV continuous preview on GitHub Pages; TEST fixture path; STAGE→PROD promotion with Changesets + approval gate
- Basic Auth on non-production AWS hostnames; public TLS on production
Outcome
The platform now has a delivery contract that matches its hosting and content contracts: provision with modules, promote with pipelines, isolate environments by purpose, and never put long-lived cloud keys in CI. Reviewers can reason about blast radius (which env, which subdomain, which content source) the same way they reason about package boundaries in the monorepo - which is the point of treating a personal site like a small product platform.
Part of prj--personal-portfolio--v3
This delivery layer closes the loop with the rest of the monorepo:
- AWS multi-subdomain hosting - console-proven topology this Terraform encodes
- Content pipeline SSG - ingest-once artifact the build workflows fan out from
- Local Traefik HTTPS mesh - local domain parity for the same four surfaces
- Spaced-repetition quiz PWA - SPA edge behaviour (
/index.htmlsoft-404) exercised on every env - Shared newspaper design system - UI consistency across every promoted surface