Side Project
frontend-portfolio
Personal portfolio you can talk to — "Lami" is a RAG chatbot on AWS Bedrock that answers questions about my work in plain English, running on self-managed EKS with zero-downtime GitOps deploys
Overview
Most personal portfolios are static pages you scroll. This one you can interrogate. Recruiters and engineers visit nelsonlamounier.com to read technical articles on DevOps and cloud architecture, download a tailored CV, leave likes and comments — and ask "Lami" questions like "What has he done with Kubernetes?" and get specific, sourced answers in seconds. Lami is a production RAG assistant backed by AWS Bedrock (Claude Sonnet + Titan Embeddings over pgvector), grounded in a real knowledge base built from my GitHub repositories. A CV cannot answer follow-up questions; this site can, and it doubles as a working demonstration of the engineering behind it. The /projects page surfaces rich Tucaken-generated case studies — stack, architecture diagrams, highlights, and key decisions — pulled from the same in-cluster BFF, making the portfolio a live window into all my engineering work. The browser application holds no cloud credentials. Every piece of dynamic content — articles, resume, chat sessions, engagement — flows through an in-cluster Backend-for-Frontend over RDS Postgres; the site pod is a pure consumer. Deployments are fully GitOps-driven: GitHub Actions builds a Docker image, pushes it to ECR, writes the new digest to AWS SSM Parameter Store, and ArgoCD Image Updater triggers an Argo Rollouts blue-green promotion with an automated analysis template — no human hand-off required. I instrumented the full browser-to-pod path with OpenTelemetry (W3C TraceContext → Grafana Alloy → Tempo), prom-client metrics scraped by Prometheus, and Grafana Faro for Real User Monitoring — a live quality assessment on 2026-07-04 rated the site at roughly 85% overall, with pages loading in about 132 ms (LCP) and a first-byte time of 40 ms (TTFB). I built the codebase as a Yarn 4 monorepo with Next.js 16 / React 19. Article and project data gracefully degrade to empty lists at Docker build time when the cluster is unreachable, and every article record is validated through Zod schemas before SEO metadata is generated. Client IP derivation trusts only the rightmost X-Forwarded-For entry appended by the ALB — never the caller-controlled leftmost value — so per-IP rate limiting at the BFF cannot be spoofed. A Jest suite of 307 tests, gated by enforced coverage thresholds and a SonarCloud quality gate, runs on every push. A docs/ knowledge base with architectural runbooks, RUM pipeline docs, live quality reports, and DORA metrics documents every design decision grounded in live Grafana output.
Stack
- Framework
- Next.jsReactTailwind CSSZodFramer MotionJest
- Language
- TypeScript
- External Service
- AWS BedrockSonarCloud
- Database
- PostgreSQL
- Infrastructure
- KubernetesArgoCDDocker
- Ci Cd
- GitHub ActionsAWS SSM Parameter Store
- Observability
- OpenTelemetryGrafana Faroprom-client
Architecture
Highlights
"Lami" — a RAG chatbot recruiters can interrogate about my work in plain English
Visitors open a chat widget and ask questions like "What has he done with Kubernetes?" or "Show me evidence of CI/CD design" and get specific, sourced answers grounded in my actual repositories and articles. Lami is a full RAG pipeline — vector + keyword retrieval over a pgvector corpus, an owner-scoped single-tenant data model with identity pinned in infrastructure, Amazon Bedrock generation, and RDS-persisted multi-turn sessions. The portfolio widget is a thin, credential-free same-origin proxy; the model, retrieval, and guardrails all run behind the in-cluster BFF. A demo video in the README shows a live response with structured metric chips and technology tags returned from the knowledge base.
Zero-credential public pod — all dynamic data flows through an in-cluster BFF
The portfolio pod holds no AWS credentials, no database connection strings, and no Bedrock keys. Articles, the resume, chat sessions, article engagement, and project case studies all arrive via HTTP calls to a ClusterIP-only public-api BFF over Kubernetes service DNS. The migration retired direct DynamoDB, S3, and Bedrock access from the site bundle entirely, and article images stream from S3 via a Next.js rewrite proxy — so the frontend pod never touches S3 directly. The graceful-degradation contract means Docker builds and ISR succeed even when the cluster is unreachable at build time.
End-to-end observability verified against live production traffic
A live quality assessment on 2026-07-04 rated the site at roughly 85% overall: pages render in about 132 ms (LCP), first-byte time is 40 ms (TTFB), and event-loop lag is ~10 ms — all benchmarked against Google Core Web Vitals standards. The three telemetry planes are: Grafana Faro (Real User Monitoring — events, web-vitals, exceptions shipped browser → Alloy → Loki), prom-client metrics scraped by Prometheus with bearer auth, and OpenTelemetry W3C TraceContext propagated from the browser through to the pod and exported to Tempo via Alloy. A panel-by-panel Grafana dashboard review (26 panels / 8 rows) was conducted live and gaps resolved in cross-repo PRs.
307-test suite with enforced coverage thresholds and a SonarCloud quality gate on every push
The Jest suite covers API routes, the MDX rendering pipeline, article-service graceful degradation, chatbot proxy contract, and new component suites for ProjectCards and ProjectCaseStudy. A flaky ESM transform failure — two MDX suites passing or failing purely by Yarn 4 hoisting order — was diagnosed and fixed by overriding the resolved Jest config to allow-list the unified/rehype ecosystem for transformation, making the suite deterministic. Coverage floors (statements/lines ≥42, branches ≥60, functions ≥35) are enforced via jest coverageThreshold; SonarCloud and branch-protection required checks gate every merge to main.
/projects page surfaces live Tucaken-generated case studies with architecture diagrams
The /projects grid was previously a relabelled article list. I rebuilt it to consume rich, evidence-grounded project records from the Tucaken pipeline: each entry shows a tagline, stack chips, type filters, and links to a native /projects/[slug] case-study page with Mermaid architecture diagrams, highlights, challenges, key decisions, and resume bullets. The owner-pinned BFF routes mean only my public projects can ever reach the UI. Both the grid and the case-study pages share the same graceful-degradation contract as the article layer — [] / null on any BFF failure — and the migration added 11 RTL component tests.
Challenges
MDX articles 500-ing in production — an LLM-emitted heading ID syntax that MDX v2 interprets as a JSX expression caused a hard 500 on the first published pipeline article. The writer agent was emitting explicit heading IDs (`## Title {#the-problem}`) for its manual table of contents; MDX v2 reads `{#the-problem}` as a JSX expression, acorn fails to parse it, and the whole page crashes.
I diagnosed the failure from the acorn parse error in production logs and extracted a tested pure `sanitizeMdx()` function that strips `{#anchor}` patterns from heading lines before the MDX compiler sees them. The strip is scoped specifically to heading lines so `{...}` in prose and inline code (e.g. `toolChoice: { tool: {...} }`) is left untouched. rehype-slug was already generating heading IDs at render, making the literal annotations redundant as well as fatal. Five unit tests cover the acorn-500 case, the non-heading safety case, and the existing Mermaid and EVIDENCE_GAP sanitisation that was already in the function. The live article rendered 200 immediately after the fix, and the universal sanitiser protects against the same pattern from any future article.
Invalid Mermaid diagrams were breaking the entire article page layout — not just the diagram box. The LLM writer emitted `style V fill:...` inside a sequenceDiagram (node styling is flowchart-only syntax), which caused mermaid.render() to throw and leave an orphan 'Syntax error in text' SVG appended to `<body>`. That orphan overflowed horizontally and pushed the whole article page sideways.
I pre-validated diagrams with `mermaid.parse(chart, { suppressErrors: true })` before calling render(). Unlike render(), parse() checks syntax without touching the DOM, so a malformed diagram now degrades to the component's own contained error box — bounded width, overflow-x-auto — with no page-breaking orphan. Valid diagrams are completely unaffected. This is defence-in-depth: it protects the layout for any malformed diagram regardless of what the writer produces, and the fix is fully isolated to the client Mermaid component.
The Jest MDX test suite was non-deterministic — two suites passed or failed purely based on how Yarn 4 hoisted packages, meaning CI could be green on one run and red on the next without any code change. The root cause was that next/jest hard-sets transformIgnorePatterns to ignore all of node_modules, and Jest ignores a file if it matches any pattern. Narrow appends could not un-ignore the unified/rehype ESM ecosystem (rehype-slug et al.), so the suites failed on a fresh install but passed when packages happened to be hoisted.
I overrode the resolved Jest configuration to allow-list the specific ESM package family for transformation, replacing the append approach that was fighting next/jest's own pattern. The override is applied at the resolved-config level so it unconditionally takes effect regardless of hoisting. The result: 19 suites / 307 tests pass deterministically. This unblocked the automated Dependabot security-update workflow, which required a stable CI gate before CI-verified grouped PRs could be trusted.
Every Docker image build silently failed after the Next 16 migration, but the breakage went unnoticed for days. Next 16 removed the --no-lint flag from next build, so the Dockerfile's `next build --no-lint` exited with 'unknown option'. Subsequent merges after the migration were docs-only and path-filtered to skip the Docker job, so CI never ran the build step to reveal the failure.
I identified the failure when PRs #47 and #49 triggered a full CI run and the Docker Build Test surfaced the exact error (Dockerfile:59). The fix was straightforward — drop --no-lint from the build step — but the incident revealed a gap: the path filter was too narrow and would silently skip the Docker job for non-application changes. I corrected both the Dockerfile and the build comment (Next 16's next build no longer runs ESLint at all, so lint stays a separate CI gate). The episode was documented in the Next 16 ADR so the suppression rationale is recorded.
Per-IP rate limiting could be bypassed entirely: both the comment route and the track-error endpoint read the leftmost X-Forwarded-For entry to derive the client IP, which the caller controls. An attacker could rotate a fake IP header per request to dodge both limits, and the stale knowledge-base articles already reproduced the wrong trust model because the concept docs themselves described the leftmost-XFF approach.
I traced the vulnerability across both routes and the KB documentation, then added lib/client-ip.ts — a helper that trusts only the rightmost XFF entry, which is appended by the shared ALB with xff_header_processing.mode=append and cannot be spoofed by the caller. I replaced the leftmost-XFF and x-real-ip reads in the comments and track-error routes, added unit tests for the spoofed-chain resolution path, and corrected the five concept docs (api-and-data-communication, chatbot-data-security, in-cluster-bff-consumer, request-routing-dns-to-pod) so the RAG knowledge base would ground the correct model on the next re-sync.
Key Decisions
BFF consumer model: site holds no AWS data credentials
The original architecture had the Next.js pod making direct calls to DynamoDB and S3, which required embedding AWS credentials in the pod environment. The alternative was to keep this pattern and tighten IAM scopes. The risk: any credential leak or SSRF would expose the data plane directly from a public pod.
Migrated all data reads (articles, resume, engagement, chat) to an in-cluster public-api Backend-for-Frontend over Kubernetes service DNS. The BFF owns every AWS credential and database connection; the site pod makes plain HTTP calls to ClusterIP-only service endpoints and holds no data-plane secrets whatsoever.
Eliminated the entire AWS credential surface from the public-facing pod — a stolen environment variable from the frontend container cannot reach RDS or Bedrock. The DynamoDB SDK, S3 SDK, and CloudFront invalidation were all removed from the site bundle. The cost: the site depends on the in-cluster BFF; mitigated by a graceful-degradation contract that returns empty lists at build time when the BFF is unreachable, so Docker builds and ISR succeed without cluster access.
Owner-ID isolation for the /projects grid — server-side pinning over username keying
The /projects page consumes Tucaken-generated case studies from a multi-user BFF. The first design keyed the portfolio's project list on a GitHub username supplied by a frontend environment variable. Two structural problems ruled this out: the oauth_connections table is UNIQUE(user_id, provider) — the username column is not unique, so two users can share a GitHub handle — and GitHub usernames can be renamed and reclaimed, making them unstable identity anchors. A misconfigured frontend env var would silently surface another user's projects. Passing the owner user-id from the frontend was also rejected as it moved the isolation key to the least-trusted layer.
The frontend consumes owner-pinned BFF routes (GET /api/projects and GET /api/projects/:slug) that filter by PORTFOLIO_OWNER_USER_ID — a server-side BFF config value. The frontend names no identity at all; the routes fail closed (empty list / 404) when the owner env is unset.
Isolation lives in exactly one place — the BFF's deployment config — enforced in SQL. Another user's projects cannot reach this UI regardless of frontend configuration. Removed the PORTFOLIO_GITHUB_USERNAME env var from the site entirely, closing the silent misconfiguration vector. The cost: the owner id must be provisioned per environment in the kubernetes-bootstrap chart.
Fully automated blue-green deploys via SSM hand-off — no kubectl from CI
The original deploy pipeline had a 'Promote Rollout' CI job that resolved the control-plane EC2 instance via describe-instances and drove argo rollouts promote over SSM. When the instance lookup started failing, the entire pipeline broke. An alternative would have been to restore the SSM-exec approach with a more robust instance lookup, but this kept CI with direct cluster control.
Removed the promote-site CI job entirely. GitHub Actions now ends at 'Update SSM (ArgoCD)' — writing the new ECR image digest to SSM Parameter Store. ArgoCD Image Updater polls SSM and triggers the Argo Rollouts blue-green promotion autonomously, using the rollout's autoPromotionSeconds and an automated analysis template to gate the switch.
CI has no kubectl or cluster access at all — the boundary between the build system and the cluster is a single SSM parameter write. Deploy failures are surfaced by ArgoCD's own health checks and the analysis template, not by a CI job. A live CD pipeline screenshot (4m 54s, green) is documented in docs/assets/. The cost: a promotion issue must be diagnosed in ArgoCD, not in the GitHub Actions log.
Rightmost X-Forwarded-For as the trusted client IP — never the caller-supplied leftmost
Both the article-comment route (which forwards the IP to the BFF rate limiter) and the track-error limiter originally read the leftmost X-Forwarded-For entry, which is caller-controlled. An attacker could rotate a fake IP per request to bypass per-IP rate limits entirely. The alternative was to use x-real-ip, but that header is also caller-spoofable in this topology.
Added lib/client-ip.ts that derives the client IP from the rightmost XFF entry — the one appended by the shared ALB, whose xff_header_processing.mode=append guarantees it is not caller-controlled. This mirrors the same helper in the BFF. Used in the comments and track-error routes; spoofable headers dropped.
Per-IP rate limiting at the BFF now binds to the real visitor IP regardless of what the caller sends in X-Forwarded-For; the spoofing vector is closed. Unit tests cover the spoofed-chain resolution path. The cost: the design assumes the ALB always appends — if the topology changes (e.g. a CDN added in front), the trust model must be re-evaluated.
Coordinated Next 16 + TypeScript 6 migration — all breaking changes fixed before merge
Dependabot opened four separate PRs for Next 16, @next/mdx 16, eslint-config-next 16, and TypeScript 6. Merging them independently would have left a broken intermediate state: Next 16 / Turbopack requires serialisable MDX plugin options (imported functions crash), the middleware file convention changed (middleware.ts → proxy.ts), eslint-config-next 16 dropped FlatCompat, and TS 6 deprecated baseUrl. Additionally, ESLint 10 was attempted but crashed at runtime with scopeManager.addGlobals is not a function — a real integration bug in the bleeding-edge plugin stack — so it was deliberately excluded.
Consolidated all four Dependabot PRs into a single coordinated upgrade (PR #38) that fixed every breaking change atomically: MDX plugins referenced by module name (strings), middleware renamed to proxy, eslint-config-next/core-web-vitals flat config with explicit @eslint/js, and baseUrl removed. ESLint stayed on 9.39 with the exclusion documented.
The full CI gate (lint, tsc, Jest, Docker build) passed green in one merge with no broken-window interim state. Subsequent PRs could safely build on the new baseline. The deliberate ESLint 10 exclusion is documented in the ADR so the next engineer knows it was tried and why it was deferred, not forgotten. The cost: a follow-up PR (#50) was still needed to remove --no-lint from the Dockerfile, which Next 16 had removed; this went unnoticed because intervening merges were docs-only and path-filtered around the Docker job.