Problem
prj--personal-portfolio--v3 is not one website. It is a platform of four static surfaces - Astro portfolio, Astro blog, Astro news-feed, and a Vite/React quiz PWA - that must ship on their own production hostnames:
| Surface | Production hostname |
|---|---|
| Portfolio | paulserban.eu |
| Blog | blog.paulserban.eu |
| Quiz | quiz.paulserban.eu |
| News-feed | news-feed.paulserban.eu |
A single bucket-behind-one-CDN collapses blast radius poorly, couples release cadence, and fights the monorepo's actual deploy units. Public S3 website hosting is the wrong security default. And Astro's trailingSlash: 'always' directory layout (post/{slug}/index.html) does not work behind CloudFront's root-only default_root_object without an explicit edge rewrite.
I needed a production topology that was correct enough to ship, auditable enough to teach, and repeatable enough to automate later - without blocking on Terraform modules or GitHub Actions OIDC first.
Solution
I bootstrapped the full edge topology in the AWS console first: one Route 53 hosted zone, four ACM certificates (DNS-validated, issued in us-east-1 for CloudFront), four private S3 buckets, four CloudFront distributions with Origin Access Control, and A/AAAA alias records per hostname.
That order was intentional. Console work is how you discover the real contract - certificate region constraints, OAC policy shape, Function association limits, SPA vs SSG error behaviour - before freezing it into modules. Terraform and CI/CD encode the same topology later; they are covered in separate case studies.
Architecture
Browser
│
▼
Route 53 (paulserban.eu zone)
│ A/AAAA alias per hostname
▼
CloudFront x 4 ← ACM TLS (us-east-1), HTTPS redirect, security headers
│ OAC (SigV4)
│ viewer-request Function: /path/ → /path/index.html
▼
S3 x 4 (private buckets; no public ACLs)
├── paulserban.eu → portfolio SSG
├── blog.paulserban.eu → blog SSG
├── quiz.paulserban.eu → quiz SPA (+ soft 404 → /index.html)
└── news-feed.paulserban.eu → news-feed SSG
| Layer | Responsibility |
|---|---|
| Route 53 | Single public hosted zone; alias records tip each hostname at its distribution |
| ACM | Per-hostname (or SAN) certs in us-east-1, DNS-validated against the zone |
| CloudFront | TLS termination, cache, compress, HSTS/security headers, OAC toward S3 |
| CF Function | Viewer-request rewrite so Astro directory URLs resolve to index.html |
| S3 | Private object store only; bucket policy allows s3:GetObject from that distribution's ARN |
Shared content media (assets.paulserban.eu) stays on a separate assets CDN; site deploys never own those objects. That keeps publishing images independent of app releases.
Approach
As architect and lead implementer, I treated hosting as a platform seam, not a deploy afterthought: each surface gets an isolated blast radius, a private origin, and edge behaviour that matches how the build actually emits files.
Platform topology first
- One stack per subdomain - bucket + distribution + DNS alias + cert validation as a repeatable unit. Failures, invalidations, and future env promotion stay per-app instead of one shared origin with path tricks.
- Private S3 + OAC, never public website endpoints - browsers never talk to S3 directly; only the matching CloudFront distribution can
GetObject. That preserves the security model later reused by Terraform. - ACM in us-east-1 - CloudFront's hard constraint; DNS validation records land in the shared
paulserban.euzone so cert lifecycle stays visible next to aliases. - Console before code - click through the seams once, write down the invariants, then automate. IaC without a proven topology just freezes mistakes faster.
Edge behaviour that matches the apps
- Astro directory indexes -
default_root_objectonly rewrites/. A CloudFront Function onviewer-requestappendsindex.htmlfor trailing-slash and extensionless paths so deep links like/portfolio/prj--…/resolve the same way they did on GitHub Pages. - Quiz SPA fallback - genuine misses soft-map to
/index.htmlso client-side routes survive refresh; SSG sites soft-map misses to/404.htmlinstead. - One Function per event type - auth (used later on test/stage) and directory rewrite share a single viewer-request Function; CloudFront will not attach two.
- Security headers at the edge - HSTS with preload intent,
X-Content-Type-Options, and a strict referrer policy applied via response headers policy, not app code.
Operational design (human-scale)
- Manual sync + invalidation for the bootstrap window - prove HTTPS, deep links, and cross-subdomain navigation before wiring OIDC deploy roles.
- Hostname inventory as a contract - the same four names the local Traefik stack mirrors as
local.*, so local and production stay isomorphic. - Defer automation deliberately - Terraform modules and GitHub Actions OIDC deploys encode this exact shape later; this case study stops at the console-proven reference architecture.
Stack
| Layer | Choices |
|---|---|
| DNS | Route 53 public hosted zone paulserban.eu |
| TLS | ACM, DNS validation, CloudFront-compatible region (us-east-1) |
| Edge | CloudFront, CachingOptimized, SNI, compress, HTTPS redirect |
| Origin | S3 private + OAC + bucket owner enforced + SSE-S3 |
| Rewrite | CloudFront Functions (cloudfront-js-2.0) on viewer-request |
| Delivery | Four independent static origins; no runtime origin servers |
Design decisions that mattered
- Four distributions over one multi-origin CDN. Isolation and independent invalidation beat a clever shared distribution for a four-app platform this size.
- OAC over legacy OAI / public buckets. Current AWS guidance, tighter bucket policies, and a clean path into Terraform.
- Keep Astro
trailingSlash: 'always'. Changing public URL shape across three SSG sites just to avoid an edge rewrite would be the wrong lever; fix delivery at the CDN. - Console bootstrap is not anti-automation. It is a spike that produces a validated topology - the same discipline as a design spike before an ADR - then Terraform becomes transcription, not invention.
- Trade-off named up front. Manual console drift risk until IaC owns the stack; accepted for a solo-maintainer bootstrap, then retired by modules and CI.
What shipped
- Four live production hostnames on HTTPS: paulserban.eu, blog, quiz, news-feed
- Private origins only - zero public S3 website endpoints on the app stacks
- Edge rewrites that make Astro deep links and quiz client routes behave correctly
- A documented reference topology later captured in
infrastructure/aws/modules/static-sitefor test / stage / prod - Local ↔ production domain parity preserved with the Traefik HTTPS mesh
Outcome
The platform has a production edge contract: one subdomain, one distribution, one private bucket - bootstrapped in the console so the architecture was proven under real DNS, TLS, and deep-link traffic before automation. Publishing surfaces stay independently releasable, origins stay closed, and the same shape scales into Terraform and CI without redesign.
Part of prj--personal-portfolio--v3
This hosting topology is the production counterpart to the monorepo's local and content seams. Related pieces:
- The content pipeline that builds all four surfaces from one SQLite artifact
- Local HTTPS Traefik + Docker Compose that mirrors these subdomains on one machine
- The spaced-repetition quiz PWA hosted at quiz.paulserban.eu
- Shared newspaper design system UI kit used across every surface
- Multi-env CI/CD & Terraform delivery - modules that encode this topology, plus DEV → TEST → STAGE → PROD promotion via GitHub OIDC