My CV site (cv.lans.cloud) is a Next.js + NestJS monorepo self-hosted on the same VPS as everything else. It had been through a couple of prior cleanups, so I wanted a proper once-over: "audit it for vulnerabilities, SEO, and whatever else it should be audited on."
That last clause is the interesting one. "Whatever else" is really four different jobs — security, SEO, accessibility, performance — that share almost nothing. Different failure modes, different tools, different mental models. Doing them one after another in a single long-running context means each lens gets a tired, cluttered version of the same attention.
So I didn't. I ran four clean-context subagents in parallel, one per lens, each writing its own report. Then I did the fixing myself. This is the story of why that split works, and the three things in the fixes — not the audits — that nearly bit me.
Why fan out instead of one big pass
A subagent starts cold. That's usually framed as a cost: it has to re-derive context you already hold. But for a broad audit it's the whole point:
- Isolation. The security agent doesn't know what the SEO agent is worried about, so it doesn't get anchored. Four independent reads beat one read wearing four hats.
- Parallelism. They're genuinely independent — no shared state — so they run at the same time instead of end to end.
- A lean main context. Each agent read dozens of files and ran
pnpm audit,tsc, greps across the tree. I got back four tight reports instead of forty file dumps clogging the conversation I still had to think in.
The rule I follow: fan out when the subtasks are independent and you only want the conclusions. Audits are exactly that shape. Delegation is a tax when work is coupled and needs the conversation history — but a broad read-only sweep is the opposite.
The reports came back with a verdict I didn't expect. Security was already solid (0 production vulnerabilities, auth done right, non-root containers). The thing actually failing was accessibility — the site didn't meet WCAG 2.1 AA, mostly on colour contrast: white text on the brand blue computed to about 2.6:1 where 4.5:1 is required. You don't find that by squinting at your own site; you find it when something computes the ratio and tells you.
The fixes were where it got interesting
Applying the findings is where the actual engineering lives, and where three things went sideways. All three share a lesson: an audit finding is a hypothesis, not a patch.
Footgun 1: the dependency "fix" that would have broken the build
The security report flagged a couple of transitive dev-only CVEs and helpfully suggested pnpm.overrides entries. One of them:
"js-yaml@<3.15.0": ">=3.15.0"
Looks reasonable. Bump anything below 3.15.0 up to at least 3.15.0. I added it, ran pnpm install, re-ran the audit — and a vulnerability I hadn't touched was suddenly resolving to a different version, via read-yaml-file.
Here's the trap: js-yaml 3.x tops out at 3.14.1. There is no 3.15.0 in the 3.x line — the fix shipped in 4.x. So >=3.15.0 has no valid 3.x target, and pnpm satisfied it with the next thing that matched: 4.1.1. My "patch a 3.x dep" override had silently force-upgraded read-yaml-file's js-yaml across a major version — and 4.x removed safeLoad, which read-yaml-file@1.1.0 still calls. That package is pulled in by changesets, my release tooling. The override would have left the audit looking cleaner while quietly arming a runtime break in a tool I only run occasionally.
The fix was to scope the override to the range that actually has a safe target:
"js-yaml@>=4.0.0 <4.2.0": "4.3.0"
and drop the 3.x rule entirely, letting read-yaml-file keep its native 3.14.1. Final pnpm audit: zero. Nothing broken. But I only caught it because I re-ran the audit and read which paths changed instead of trusting the green checkmark.
Footgun 2: the CSP that blocked my own WebSocket
I added a Content-Security-Policy header. To keep it correct in both dev and prod I derived the allowed origins from environment variables rather than hard-coding them:
const apiOrigin = originOf(process.env.NEXT_PUBLIC_API_URL, 'http://localhost:4000');
const wsOrigin = originOf(process.env.NEXT_PUBLIC_WS_URL, 'ws://localhost:4000');
Built, deployed, checked the live header with curl. It was there, well-formed, all directives present. Looked done.
It wasn't. The connect-src read ws://localhost:4000 — in production. NEXT_PUBLIC_API_URL is passed as a build arg to the web image, so apiOrigin was correct. But NEXT_PUBLIC_WS_URL isn't a build arg, so at next build time it was undefined and fell back to localhost. The site has a live "lab" panel that opens a graphql-ws subscription to wss://api.cv.lans.cloud — and my CSP would refuse that connection for every visitor.
curl will never catch this. The header is present and syntactically fine; only a real browser actually tries the WebSocket and gets refused. So I drove one — the cached Playwright Chromium on the box — and watched the console:
page.on('console', (msg) => {
if (/content security policy|refused to (load|connect)/i.test(msg.text()))
cspViolations.push(msg.text());
});
The fix was to stop trusting an env var that isn't there at build time, and derive the socket origin from the API origin instead (they share a host, only the scheme differs):
const wsOrigin = process.env.NEXT_PUBLIC_WS_URL
? originOf(process.env.NEXT_PUBLIC_WS_URL)
: apiOrigin.replace(/^http/, 'ws'); // https://… → wss://…
Re-ran the headless check against the live site: 0 CSP violations, WebSocket connects. That browser check is now a little script I keep around.
Footgun 3: the icon that 500'd only in production
The audit noted a missing apple-touch-icon. I generated one with Next's ImageResponse — a nice SL monogram on the brand gradient — reading a bundled font from disk:
const monoBold = readFileSync(join(process.cwd(), 'src/app/_assets/mono-bold.ttf'));
It built cleanly and prerendered a perfect 180×180 PNG. In the container, GET /apple-icon returned 500.
output: 'standalone' only copies .next/standalone, .next/static, and public into the runtime image — not src/. So the font file that exists at build time is gone at runtime. Worse, the read is at module scope, so it runs the instant the server loads the route module, throwing immediately. And process.cwd() is /app/apps/web during the build but /app at runtime, so even the path is wrong.
I could have chased the font into the runtime image. But the icon is completely static — there's no reason to run anything to serve it. So I took the PNG the build had already generated and promoted it to a plain static asset:
cp apps/web/.next/server/app/apple-icon.body apps/web/src/app/apple-icon.png
rm apps/web/src/app/apple-icon.tsx
app/apple-icon.png is served directly by Next — no handler, no font, no runtime execution, nothing to 500. The route went from ƒ (dynamic) to ○ (static, 0 B). Sometimes the fix for "this dynamic thing breaks in prod" is to notice it never needed to be dynamic.
What actually shipped
Once the footguns were defused, the coordinated pass landed a lot: contrast tokens and a dozen other a11y fixes, a CSP, dependency CVEs to zero, JWT algorithm pinning, digest-pinned Docker images, a static homepage with lazy-loaded heavy sections (first-load JS 334 KB → 236 KB), GIN indexes on the full-text search, a Zod env schema, and self-hosted Umami analytics wired the same way the blog does it. Everything verified: types, tests, a real browser, and row-count checks proving production content was never touched.
The lessons, distilled
- Fan out the reading, keep the writing. Four clean-context agents auditing in parallel is a genuine multiplier. But the fixes touched overlapping files and needed judgment, so I coordinated those myself — delegating the reads, not the surgery.
- A finding is a hypothesis. Every one of the three footguns came from applying a reasonable-looking fix and then actually verifying it — re-reading the dependency graph, driving a browser, hitting the endpoint in the real runtime. The green checkmark lies more often than you'd like.
curlsees headers; browsers see behaviour. A present, well-formed CSP header told me nothing about whether the site's own WebSocket would connect. Keep a headless browser in your verification toolkit.- Static beats dynamic for anything that can be. The icon, the homepage — the robust version was the one that runs no code at request time.
The audits took minutes. The care in the fixes took the afternoon. That ratio feels about right.