paulserban.eu

Portfolio Edition

Paul Serban

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

← Back to portfolio

Featured

Multi-Env CI/CD & Terraform Delivery

Four surfaces, four environments, one promotion contract

Role: Architect & lead implementer

  • #aws
  • #ci/cd
  • #cloudfront
  • #github actions
  • #oidc

Problem

Console-proven AWS hosting for four production domains still needed repeatable provisioning, environment isolation, and a release path that could promote the same four surfaces without long-lived AWS keys or rebuild-per-host chaos.

Approach

Encoded the proven edge topology as reusable Terraform modules, then designed trunk-based GitHub Actions pipelines that ingest once, build four apps in parallel, and promote per-domain artifacts through DEV → TEST → STAGE → PROD with OIDC-scoped deploy roles and a human prod gate.

Outcome

Every non-local environment is provisioned the same way and deployed the same way - fixtures for automation, live content for release rehearsal, public prod behind approval - with zero AWS access keys in CI.

environments
4
surfaces_per_env
4
long_lived_aws_keys
0
View code Live demo

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:

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
ConcernOwnership
TopologyTerraform static-site module - S3, ACM, OAC, CloudFront, Function, Route 53
Deploy identityTerraform github-oidc-deploy-role - trust scoped to repo:…:environment:{env}
Build contractReusable _build-site.yaml - ingest → parallel builds → optional merge
Promotiondeploy-dev / deploy-test / release - different triggers, content sources, and gates
Secrets modelGitHub 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

EnvTriggerContentHostingGate
DEVEvery main push (+ successful news-sync)Live private content repoGitHub Pages merged preview (/home, /blog, /quiz, /news)Continuous
TESTworkflow_dispatchCommitted fixtures (content/test)test.{,blog.,quiz.,news-feed.}paulserban.eu via S3 + CloudFront, Basic AuthManual / automation
STAGEChangesets version publishLive contentstage.* subdomains, Basic AuthRelease rehearsal
PRODAfter stage successLive content, prod absolute URLsPublic apex + subdomainsGitHub Environment production-approval

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:

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

Operational design (lead-engineer scale)

Stack

LayerChoices
IaCTerraform ≥ 1.6, reusable modules, per-env roots
EdgeCloudFront, OAC, ACM (us-east-1), Functions (cloudfront-js-2.0)
Origin / DNSPrivate S3 + Route 53 aliases on paulserban.eu
CIGitHub Actions, reusable workflow_call, Changesets
Auth to AWSGitHub OIDC → IAM role; no long-lived access keys
PreviewGitHub Pages (merged multi-app bundle)

Design decisions that mattered

What shipped

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: