Problem
A platform of independently deployable static surfaces - an SSG site, a blog, an SPA, a news feed - must ship on their own HTTPS hostnames. A single bucket-behind-one-CDN collapses blast radius poorly, couples release cadence, and fights the actual deploy units. Public S3 website hosting is the wrong security default. And SSG directory layouts (post/{slug}/index.html) do not work behind CloudFront's root-only default_root_object without an explicit edge rewrite.
I needed a topology that was correct enough to ship, auditable enough to teach, and repeatable enough to automate later - without blocking on GitHub Actions OIDC first.
The prototype in prj--aws-multi-subdomain-hosting is that contract as a module: one Terraform instance equals one subdomain, one distribution, one private bucket.
Solution
I bootstrapped the full edge topology in the AWS console first: one Route 53 hosted zone, ACM certificates (DNS-validated, issued in us-east-1 for CloudFront), private S3 buckets, 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. The POC repo is that transcription. CI/CD that deploys into this topology is a separate case study.
Architecture
Browser
│
▼
Route 53 (one public hosted 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)
├── apex -> SSG
├── blog.* -> SSG
├── app.* -> SPA (+ soft 404 -> /index.html)
└── news.* -> 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 SSG directory URLs resolve to index.html |
| S3 | Private object store only; bucket policy allows s3:GetObject from that distribution's ARN |
The SPA instance maps 403/404 to /index.html with HTTP 200 so client-side routes survive refresh. SSG instances return a hard 404 with /404.html. Shared content media belongs on a separate assets CDN; site deploys never own those objects.
Approach
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. - ACM in us-east-1 - CloudFront's hard constraint; DNS validation records land in the shared hosted zone 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. The click-through is documented in the POC as
_docs/console-bootstrap.md.
Edge behaviour that matches the apps
- SSG directory indexes -
default_root_objectonly rewrites/. A CloudFront Function onviewer-requestappendsindex.htmlfor trailing-slash and extensionless paths so deep links resolve. - SPA fallback - genuine misses soft-map to
/index.htmlso client-side routes survive refresh; SSG sites map misses to/404.htmlwith a hard 404 instead. - One Function per event type - auth (used later on non-prod) 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 names the local Traefik stack mirrors as
local.*, so local and production stay isomorphic. - Defer delivery automation deliberately - GitHub Actions OIDC deploys encode how artifacts land in these buckets later; this case study stops at the edge topology.
Stack
| Layer | Choices |
|---|---|
| DNS | Route 53 public hosted zone |
| 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 | Independent static origins; no runtime origin servers |
| IaC | Terraform module modules/static-site, four instances in examples/multi-subdomain |
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.
- Fix delivery at the CDN, not the URL shape. Changing public paths across three SSG sites just to avoid an edge rewrite would be the wrong lever.
- 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 the module.
What shipped
- A domain-agnostic Terraform module and a four-surface example in prj--aws-multi-subdomain-hosting
- Private origins only - zero public S3 website endpoints
- Edge rewrites that make SSG deep links and SPA client routes behave correctly
- Live production hostnames applying the same shape: paulserban.eu, blog, quiz, news-feed
- 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.
Reference implementation: Personal Portfolio v3
This repository is the generic prototype. The same module shape - private S3, OAC, ACM in us-east-1, viewer-request Function, Route 53 aliases - is the production edge of Personal Portfolio v3: paulserban.eu, blog.paulserban.eu, quiz.paulserban.eu, and news-feed.paulserban.eu, with copies of the module at infrastructure/aws/modules/static-site and env wiring in infrastructure/aws/envs/{test,stage,prod}.
Where the POC uses placeholder hostnames (example.com, blog.*, app.*, news.*) and leaves Basic Auth off, v3 applies the identical stack to four real Astro/React surfaces, gates non-prod with Basic Auth, and adds GitHub OIDC deploy roles so CI can sync and invalidate without long-lived keys.