Platform Engineering Guide · v2.1

How the platform is built.

The authoritative technical companion to the Sol del Sur design system. Where the design system defines what the platform looks like, this guide defines how it is built, structured, deployed, tested, and secured — every architectural decision, naming convention, and component pattern, in one place.

StatusLiving document
UpdatedMay 2026
StackNext.js · TS · AWS
Cost target< €5 / month
Companiondesign-system.md
§1 · §21

One platform, two faces.

markus-mildner.com is a unified personal and professional platform: a public website, a blog, and a suite of private productivity tools. It serves two audiences at once — professional visitors, and Markus himself as a daily user of his own tools. The public face (homepage, blog, CV) is expressive and editorial; the private interior (tools, finance, study, settings) is denser and functional. Both share one codebase, design system, and infrastructure.

SurfaceDomainCharacterAuthVisibility
Homepagemarkus-mildner.comLanding & public infoNonePublic
AI Conciergemarkus-mildner.comOn the homepage — no dedicated pageOptionalPublic
Developer CVmarkus-mildner.com/cvProfessional credibilityNonePublic
Blogblog.markus-mildner.comLong-form proseNonePublic
Financial Plannerapp.markus-mildner.com/financeData-focusedCognito (edge)Private
Study Toolsapp.markus-mildner.com/studyAcademic workspaceCognitoPrivate
Content Toolsapp.markus-mildner.com/toolsCreative workspaceCognitoPrivate
Settingsapp.markus-mildner.com/settingsOwner control panelCognito (owner)Private
§2

Six non-negotiable constraints.

Every technical decision is evaluated against these, in order of priority. Anything that contradicts them requires an explicit decision recorded in the guide.

1

AWS & Terraform are fixed

The entire infrastructure runs on AWS, managed by Terraform. No other cloud. No Vercel, Netlify, or Render. No managed hosting that bypasses the existing IaC.

2

Hosting cost near zero

Target under €5/month total. No always-on compute, no RDS, no ECS. S3 + CloudFront static hosting is the default serving model.

3

TypeScript everywhere, strict

All frontend and Lambda code is TypeScript with strict: true. No any. This exists primarily to maximise AI code-generation quality.

4

AI-assistability is first-class

Folder structures, naming, and component design must be predictable enough that Claude generates correct, on-brand code from a brief prompt and this document.

5

The design system is law

All visual decisions defer to markus-mildner-design-system.md. No inline colour values, no arbitrary spacing, no off-scale fonts.

6

Security for future exposure

Even private-only features are built as if they will become public or multi-user. Retrofitting security is harder than designing for it upfront.

§3

The technology stack.

Each choice optimises for AI-generation quality, near-zero cost, and the fixed AWS + Terraform base.

LayerChoiceReason
FrameworkNext.js 15 App RouterBest-in-class AI generation; static export for S3; one model for static + dynamic
LanguageTypeScript 5 (strict)Type correctness; dramatically better AI accuracy
StylingTailwind CSS v4Native CSS-variable tokens; atomic utilities; no runtime overhead
Primitivesshadcn/ui (reskinned)Accessible base, fully overridden with Sol del Sur tokens
IconsPhosphorWarmer than Lucide; regular weight default
MonorepoTurborepoBuild caching; workspace orchestration
ComputeLambda (Node 20)On-demand; free tier covers personal use
APIAPI Gateway HTTP API~70% cheaper than REST API
AuthCognitoExisting User Pool; App Client per app
DatabaseDynamoDB (on-demand)Key-value patterns; near-zero cost at scale
SecretsSSM Parameter StoreSecureString; fetched at Lambda init
TestingVitest · RTL · PlaywrightFast unit, behaviour-based component, cross-browser e2e

Explicitly rejected

  • Vercel / Netlify — bypasses Terraform IaC; higher cost at scale.
  • Astro — mixed .astro/.tsx reduces AI generation clarity.
  • SSR by default — requires compute. output: 'export' is the default; SSR is opt-in per route.
  • RDS / PostgreSQL — always-on; ~€7/month minimum. DynamoDB covers all current patterns.
  • Prisma / GraphQL — over-abstraction for this scale. AWS SDK + REST is sufficient.
  • CSS Modules / Styled Components — breaks the Tailwind utility model.
  • any in TypeScript · Jest · Lucide — banned, replaced by Vitest, superseded by Phosphor.
§4

One Turborepo, one-directional graph.

Apps, shared packages, Lambdas, and Terraform live in a single monorepo. Dependencies flow one way; circular imports fail the build.

markus-mildner.com — monorepo layout
apps/
  web/          # markus-mildner.com (public + tools)
  blog/         # blog.markus-mildner.com (static export)
  finance/      # finance.… (SPA, Cognito-gated)
packages/
  ui/           # Shared React components (Sol del Sur)
  tokens/       # CSS variables + TS constants
  config/       # Shared tsconfig, eslint, tailwind, vitest
  types/        # Shared interfaces + API contracts
lambdas/
  chatbot/  visitor-tracker/  contact-form/
  notifier/  config-api/
terraform/      # All AWS infra (modules + main.tf)
content-machine/ # AI blog pipeline
docs/          # design-system.md + platform-guide.md
turbo.json   package.json   # npm workspaces root

The dependency rule. packages/types never imports from ui or tokens — it is the only package Lambdas consume, and Lambdas must never bundle frontend code. A component starts in apps/web/components and is promoted to packages/ui only when a second app needs it. Over-abstraction is as harmful as duplication.

§5

Static by default, dynamic by exception.

The baseline next.config.ts sets output: 'export' so every app deploys as pure static files to S3. Server compute is opt-in, never the default.

Rendering decision tree
Does this page change per-request?
  No  → output: 'export' (default). Pure static. S3 serves it.
  Yes → Is it an API route?
        Yes → Standalone Lambda + API Gateway (not a Next API route)
        No  → Client-side data fetching (SWR) within a static shell.
              force-dynamic only as a last resort.

Server vs Client Components

Default to Server Components. Add 'use client' only at the leaves — never make a whole layout Client because one button needs onClick.

Needs 'use client'

  • useState / useEffect / useRef
  • Browser APIs (window, document)
  • Event handlers (onClick, onChange)
  • Browser-context libraries
  • Real-time subscriptions

Stays a Server Component

  • async data fetching
  • Reading props, rendering JSX
  • await at component level
  • Environment variables
  • Static content rendering
§6

Design tokens, in code.

The Sol del Sur tokens live as CSS custom properties in packages/tokens/tokens.css and are bridged into Tailwind v4 via @theme. Every colour, spacing, type, and motion value comes from this file — never hardcoded. This is what makes AI-generated components correct by default.

packages/tokens/src/tokens.css — excerpt
/* Accent — terracotta / sol */
--color-accent:      #C8813A;   /* dark: #E09B52 */
--color-teal:        #1A7A6E;
--color-canary:      #D4A843;
/* Surfaces — warm stone */
--color-bg:          #F5F1EA;   --color-surface: #FDFAF5;
--color-text:        #2A2420;   --color-text-muted: #7A7268;
/* Fonts */
--font-serif: 'Cormorant Garamond';  --font-sans: 'Geist';
--font-mono:  'Geist Mono';          --font-serif-long: 'Lora';
/* Spacing · radius · motion */
--space-4: 16px;  --radius-btn: 6px;  --radius-card: 10px;
--ease-modal: cubic-bezier(0.2, 0, 0, 1);  --duration-smooth: 200ms;

This page proves it. assets/sol.css is the static-HTML implementation of exactly these tokens — the same accent #C8813A, the same warm-stone surfaces, the same Cormorant / Geist / Geist Mono system, the same cubic-bezier(0.2,0,0,1) signature curve. After Tailwind's @theme resolves them, classes like bg-surface, text-accent, and rounded-card map straight onto these values.

§7

One component anatomy.

Every shared component in packages/ui follows the same shape — so AI tools generate it correctly every time.

packages/ui/src/components/Button.tsx
// 1 · named props interface, extends the native element
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
  size?: 'compact' | 'default' | 'prominent';
}
// 2 · variant maps as const objects, not inline ternaries
const variantClasses = {
  primary:   'bg-accent text-[#FFF5EA] hover:opacity-90',
  secondary: 'border border-border-strong hover:bg-surface-2',
} as const;
// 3 · forwardRef on interactive leaves · cn() merges className last
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(...);
  • Named exports only — no default exports; the import name must match the component.
  • className contract — every component accepts className and merges it last with cn().
  • Barrel exports — consumers import from index.ts, never a deep file path.
  • Extensible Tag — four built-in variants (sol · teal · canary · neutral); custom categories registered at runtime via config (§11).
§8

Lambdas: thin edge, pure core.

Every Lambda splits HTTP concerns from business logic. index.ts parses, validates, and shapes responses; handler.ts is a pure, fully-testable function. Secrets load once at cold start from SSM.

lambdas/*/src — identical structure
src/
  index.ts    # HTTP parsing, validation, response shaping
  handler.ts  # pure business logic — typed in, typed out
  types.ts    # input/output types
  lib/        # AWS SDK wrappers (ssm.ts, dynamodb.ts)
__tests__/    # handler.test.ts + index.test.ts
EndpointLambdaNotes
POST /chatchatbotRate-limited; 4000-char input guard
POST /contactcontact-formWrites S3 leads/, triggers notifier
POST /visitorvisitor-trackerDynamoDB dedup, SHA-256 IP hash
GET/PUT /configconfig-apiRead public; write owner-only (Cognito)
GET /healthMock integration, no Lambda, returns 200
§9

All infrastructure is Terraform.

Resources are never provisioned manually. A module encapsulates one reusable pattern; each app is a new instantiation, not a copy-paste. Account IDs, regions, and ARNs are never hardcoded.

Naming · {project}-{purpose}-{year}

ResourceExample
S3markus-mildner-web-2026
Lambdamarkus-website-chatbot
DynamoDBmarkus-website-visitors
SSM/markus-website/anthropic-api-key

CloudFront cache strategy

cache-control by path
/*.html            → no-cache, no-store, must-revalidate
/_next/static/*    → public, max-age=31536000, immutable
/images, /fonts    → public, max-age=31536000, immutable
/api/*             → no-store
§10 · §11

DynamoDB, no ORM — plus a config table.

Direct AWS SDK; types model the DynamoDB shape explicitly. Raw IPs are never stored — always a SHA-256 hash (GDPR). A dedicated markus-website-config table holds runtime settings that change without a deploy: feature flags, tag categories, site metadata, chat model selection.

markus-website-config — table shape
PK: namespace   # "platform" | "chat" | "tags" | "features"
SK: key         # "site_title" | "model" | "custom_categories"
  value       # JSON-serialised string
  public      # readable without auth?
  updated_by  # Cognito sub (owner identity)

The /settings page (owner-only) edits each namespace independently, saving on blur with optimistic updates and rollback on error. The four built-in tag variants are never stored here — custom categories are always additive, rendered via the Tag component's dynamicStyle prop.

§12

AI features, structured.

System prompts are TypeScript constants in packages/types/src/prompts.ts — never magic strings scattered across Lambdas. The chatbot streams responses as server-sent events.

lambdas/chatbot — streaming handler
export const handler = awslambda.streamifyResponse(async (event, stream) => {
  stream.setContentType('text/event-stream');
  const res = await anthropic.messages.stream({
    model:  'claude-sonnet-4-20250514',
    system: SYSTEM_PROMPTS.chatbot,
    messages: [{ role: 'user', content: event.body ?? '' }],
  });
  for await (const chunk of res) /* write text deltas */;
  stream.end();
});
§13

Targeted confidence, not coverage.

One developer, personal platform — 100% coverage would waste time. Test what you cannot see at a glance: pure logic, edge cases, error handling, and shared components many surfaces depend on. Don't test that a <div> renders.

CategoryTest typeWhy
Lambda handler.tsUnit (Vitest)Pure functions; critical; easy to isolate
Input validationUnitMissing fields, overlong input, bad JSON
packages/uiComponent (RTL)Shared by many apps; regressions are expensive
Auth · homepage · chatE2E (Playwright)Critical paths; need a real browser
Page-specific sectionsNot testedLow reuse; visually obvious; high churn
§14 · §15

Deploy on push, secure by design.

GitHub Actions deploys on push to main. Tests, type-check, and lint gate every deploy; workflow_dispatch allows a manual redeploy from the GitHub mobile app.

.github/workflows/deploy-web.yml — flow
push → main  ──▶  test job   # turbo type-check lint test --filter=web…
                   │
                   ▼
                deploy job  # turbo build → aws s3 sync → CloudFront invalidate

Security baseline

  • Secrets in SSM SecureString, fetched at cold start — never in env vars or code.
  • Input guards on every Lambda; length limits prevent prompt injection & runaway cost.
  • Config writes require a valid Cognito owner token; an owner-sub check beyond auth.
  • Structured JSON logging only — no console.log, no raw IPs, ever.
§18 · §19

Naming & the 14 rules.

Conventions are enforced by ESLint and the strict type-checker so AI generation lands consistently. Components PascalCase.tsx, hooks useCamelCase.ts, types no I-prefix, constants SCREAMING_SNAKE_CASE, const-objects instead of enums.

  1. TypeScript strict always on.
  2. No any — narrow unknown or model it.
  3. No hardcoded colour, spacing, or font values.
  4. No default exports from components.
  5. Every interactive element has a focus state.
  6. Every icon-only button has an aria-label.
  7. Every image has alt text (alt="" if decorative).
  8. One h1 per page.
  9. No pill buttons — rounded-btn (6px).
  10. Geist is the UI font — no Inter / Roboto.
  11. No console.log in Lambdas — JSON only.
  12. No raw IPs stored — SHA-256 always.
  13. Tests run before deploy; a failure blocks it.
  14. npm audit in CI; high/critical blocks deploy.