Build Masterclass

67projects.app

Six decisions that turned a one-person CMS site into something you can defend in a security audit. Each one opens with the obvious move, shows where it breaks, then walks the reasoning to the choice that held.

What you'll learn

6 chapters

Scroll to begin ↓

01·One app, two route groups

A public site and a full admin panel, kept alive by one person.

You need a marketing site the world reads, and an admin panel only you touch. Both have to ship, deploy, and stay patched. And there's exactly one set of hands to do it.

The trap

The clean-looking move is to split them: a Next.js frontend talking over HTTP to a headless CMS running as its own service. Then you live with it. Two deploys, two runtimes to patch, two sets of env vars, and a network hop on every read. Worse, the API's types and the frontend's types are now two things you keep in sync by hand, and they drift the first week you're busy.

The decision

One Next.js 15 process serves both the public site and the embedded Payload admin: two App Router route groups, one Postgres, one generated type set.

Payload 3 mounts inside the App Router, so there's no separate server to stand up. The admin, the REST/GraphQL API, and the pages all import the same payload-types.ts. A schema change physically cannot desync the frontend from the API, because there is no wire format between them to drift. For a one-person shipping challenge, one process to deploy and one database to back up beats the operational tax of running two services that have to agree.

Road not taken

A standalone headless CMS gives cleaner separation on paper. In practice it buys a second deploy target, a second runtime to keep patched, a round-trip on every CMS read, and hand-maintained type parity across an HTTP boundary. All cost, no payoff at this scale.

02·Contact create lockdown

Your form is rate-limited. The table behind it has a public REST endpoint.

The contact form throttles requests and hashes the sender's IP before anything hits the database. But that same table is a Payload collection, which means Payload also exposes it over REST.

The trap

The easy path is to leave the collection's create access open so the form just works. But look at where the protections actually live: the rate limit and the IP hashing are in the Server Action, not in Payload. So anyone who skips the form and POSTs straight to /api/contactSubmissions skips both. No throttle on the spam. And with no field-level guard, they can even hand-set the ipHash field and poison your abuse correlation. The form is locked. The back door is wide open.

Server Action rate limit + IP hashing Collection create: local-API only

Toggle a layer off to see the gap it leaves. Drop the collection guard and the public REST door reopens; drop the action and there's no throttle at all. Both have to hold.

The decision

Restrict the collection's create to req.payloadAPI === 'local': only the Local API call inside the Server Action can write a submission.

A single enforced entry point is worthless if there's a second, unguarded one into the same table. That's the whole game here. Gating create on payloadAPI === 'local' makes the Server Action the only path that reaches the DB. The public write surface for that collection is simply closed, so there's nothing left to walk the throttle around. Two layers cover one table: the action enforces the policy, and the collection enforces who's even allowed to run the action's writes.

Road not taken

You could re-implement the rate limit inside a Payload beforeChange hook so REST is covered too. But that splits the same control across two code paths that now have to stay in agreement forever. One enforced door is easier to reason about than two that must never disagree.

03·CSP with a per-request nonce

A policy strict enough to kill injected scripts, on a framework that injects its own.

You want a Content-Security-Policy that an injected <script> can't survive. The catch: Next.js emits its own inline bootstrap scripts on every render, and a naive strict policy blocks those too.

The trap

The shortcut is script-src 'unsafe-inline', so the framework's inline scripts run. That one keyword re-arms every XSS payload you were trying to stop. An injected <script> is also "inline," so the policy waves it straight through. The header reads as strict in a scanner and protects nothing.

Per-request nonce (middleware) script-src 'strict-dynamic'

The nonce admits this request's scripts and nothing else; 'strict-dynamic' lets those trusted scripts load the rest of the bundle without listing every chunk URL.

The decision

Mint a fresh nonce per request in middleware, pin script-src to 'self' 'nonce-…' 'strict-dynamic', and stamp it into both the request header and the response CSP.

A per-request nonce means only scripts carrying this request's token are allowed to run. An injected inline script has no way to learn the nonce, so it gets rejected outright. 'strict-dynamic' then lets your nonce'd bootstrap pull in the rest of the bundle without you enumerating every chunk URL, which is exactly what keeps a strict policy compatible with Next's dynamic loading. Because the nonce is regenerated from crypto.randomUUID() on every request, it can't be captured once and replayed.

Road not taken

A static nonce baked into config is trivially copyable by anyone who reads one page of source, which defeats the entire mechanism. 'unsafe-inline' is the simplest option and is precisely the keyword this threat model exists to keep out.

04·IP hashing with a daily-rotating salt

You need the sender's IP. GDPR says you can't keep it.

To rate-limit the contact form and spot abuse, you have to key on the sender's IP. But an IP is personal data under GDPR, and you're persisting submissions to a database that someone, someday, will inspect.

The trap

The tempting middle ground is to store a hash of the IP with a fixed salt and call it anonymized. It isn't really anonymous. The IPv4 space is only about four billion values, small enough that anyone holding the salt can precompute every possible hash and reverse the lot. You've kept the personal data and added a step that lets you believe you didn't.

The decision

Hash the IP with SHA-256 over a salt that rotates every day: DAILY_SALT_SECRET:YYYY-MM-DD | ip.

Rotating the salt daily means a given hash is only correlatable within one day. That happens to be exactly the window you need for same-day rate limiting and abuse correlation, and not a minute more. Once the date turns, yesterday's hashes can no longer be linked to today's, and there's no stored key sitting around to walk the small IPv4 space back to an address. You keep the operational signal you actually use, while the data minimization becomes a property of the design rather than a line in a policy doc.

Road not taken

A single fixed salt would let any two submissions ever be correlated by IP, and it would be brute-forceable to the original address given the secret. That preserves the exact linkability data minimization asks you to give up.

05·Validate the environment before boot

A missing env var doesn't crash. It ships, quietly broken.

A dozen environment variables hold the keys to the whole app: the Payload secret, the database URL, the IP-hash salt, the proxy-trust flag. A typo or a missing one is a silent security hole.

The trap

The default habit is to read process.env.X lazily, wherever each value is needed. The problem is what doesn't happen when one is wrong. A missing DAILY_SALT_SECRET doesn't crash. Instead undefined flows into the hash and every IP suddenly hashes the same way. A too-short PAYLOAD_SECRET quietly weakens session signing. The app boots green and ships broken, and you learn about it from the symptom, weeks later.

The decision

Parse the entire environment through a Zod schema on first import, and throw, listing every invalid variable, if anything fails. A misconfigured process refuses to boot.

Validating once at startup converts a whole class of latent runtime failures into a loud, immediate crash that names the exact field and reason. The schema also encodes the real constraints, not only presence: PAYLOAD_SECRET at least 32 characters, DAILY_SALT_SECRET at least 16, URLs that must actually parse. So "set" is never mistaken for "valid." Every other module then imports the one parsed env object, which gives a single typed source of truth and removes the stray process.env reads that could otherwise disagree with each other.

Road not taken

Scattering process.env.X ?? default reads spreads the failure surface across the codebase and lets a misconfigured deploy run in a degraded state instead of failing fast. That's the opposite of fail-closed.

06·Placeholder secrets at build, real secrets at runtime

Your boot guard runs during the build. The build has no business holding real secrets.

Your env validator throws on boot if secrets are missing. And next build runs your code, which means it boots that validator too. But the build happens in CI and Docker, where your real production secrets must never live.

The trap

So you get cornered into the obvious fix: pass the real secrets as Docker build args so the build passes validation. Now your production PAYLOAD_SECRET is baked into an image layer, readable by anyone who can run docker history on the image. Every rebuild leaks it again. The fail-fast guard you were proud of has forced a choice between a failing build and a permanent secret in the image.

The decision

Feed obvious placeholder values as build ARGs, just enough to satisfy Zod, and let Coolify inject the real secrets as runtime env. The one exception is NEXT_PUBLIC_SERVER_URL, a real build arg because it's baked into the client bundle.

The build only needs the environment to be shaped right so validation passes and Next can compile. It never needs the real values, because nothing security-sensitive is evaluated at build time. So the placeholders are deliberately, visibly fake (build-time-placeholder-…), and the real PAYLOAD_SECRET, DATABASE_URL, and DAILY_SALT_SECRET arrive only when the container starts, from Coolify's runtime env. The single value that genuinely must be correct at build is the public server URL, because client-side NEXT_PUBLIC_* is inlined into the bundle. So that one, and only that one, is a real build arg.

Road not taken

Disabling env validation for the build would remove the very fail-fast guard the rest of the project leans on, and it would let a truly misconfigured image build clean. Baking real secrets in trades a runtime concern for a permanent one embedded in image history.