paulserban.eu

Portfolio Edition

Paul Serban

AI & full-stack engineer · agentic platforms · distributed systems

← Back to portfolio

AWS Multi-Subdomain Static Hosting

Partial system - core mechanism proven in code

One subdomain, one distribution, one private bucket

Role: Architect & lead implementer

  • #acm
  • #aws
  • #cloudfront
  • #jamstack
  • #route 53

Problem

Independently deployable static surfaces needed production HTTPS subdomains with CDN delivery, private origins, and deep-link behaviour that matched how the apps emit HTML - without waiting on CI.

Approach

Designed a one-subdomain / one-distribution / one-private-bucket topology in the AWS console - ACM in us-east-1, OAC-locked S3, viewer-request rewrites for SSG directories, and SPA fallback - then encoded it as a domain-agnostic Terraform module.

Outcome

A proven reference topology in Terraform; live production URLs for four surfaces on paulserban.eu with TLS, edge caching, and correct deep links.

surfaces
4
module_instances
4
public_buckets
0
View code Live demo

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
LayerResponsibility
Route 53Single public hosted zone; alias records tip each hostname at its distribution
ACMPer-hostname (or SAN) certs in us-east-1, DNS-validated against the zone
CloudFrontTLS termination, cache, compress, HSTS/security headers, OAC toward S3
CF FunctionViewer-request rewrite so SSG directory URLs resolve to index.html
S3Private 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

Edge behaviour that matches the apps

Operational design (human-scale)

Stack

LayerChoices
DNSRoute 53 public hosted zone
TLSACM, DNS validation, CloudFront-compatible region (us-east-1)
EdgeCloudFront, CachingOptimized, SNI, compress, HTTPS redirect
OriginS3 private + OAC + bucket owner enforced + SSE-S3
RewriteCloudFront Functions (cloudfront-js-2.0) on viewer-request
DeliveryIndependent static origins; no runtime origin servers
IaCTerraform module modules/static-site, four instances in examples/multi-subdomain

Design decisions that mattered

What shipped

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.