New Client Site: Launch Checklist

End-to-end launch procedure for a SolidStart client site on Cloudflare Workers: repo scaffold, security-middleware port, env access, Resend and Turnstile wiring, image discipline, deploy, and post-deploy CSP verification.

Prerequisites

Before starting, confirm the following:

  • gh is authenticated as the ValkyrieNexus org (all client repos are private under it)
  • Node.js v18+ and npm v9+ are installed
  • You have Cloudflare account access for the client's zone
  • You have a Resend account for the contact-form email and a Turnstile site key for the zone
  • The two reference repos are checked out locally: AAFireworks (cleanest security-middleware reference) and matts-delicious-meat (layout/stack reference)

Every client site shares one proven stack. The job here is porting it cleanly, not improvising a new one.

Step 1: Scaffold the Repo

Create a private repo under the ValkyrieNexus org. The stack, no substitutions:

  • SolidStart with the cloudflare-module preset. NOT cloudflare-pages, which was deprecated April 2025.
  • Cloudflare Workers free tier, which means a hard 3 MB bundle limit
  • CSS Modules + CSS Custom Properties. No Tailwind.
  • Conventional Commits, feature branches, PRs into main, never force-push shared branches

Add a README, a .env.example, and a .gitignore that covers .claude/, .wrangler/, app.config.timestamp_*.js, and other build/agent artifacts. None of those belong in commits.

Verify:

gh repo view ValkyrieNexus/<repo-name> --json visibility
grep cloudflare-module app.config.ts

Expected: "visibility": "PRIVATE" and the preset name in the app config.

Step 2: Port the Security Middleware

AAFireworks is the canonical reference. Copy these files and adapt:

  • src/middleware.ts
  • src/lib/security-headers.ts
  • src/lib/nonce.ts
  • src/entry-server.tsx (nonce wiring)
  • src/routes/api/csp-report.ts (Report-Only receiver)
  • app.config.ts (Vite + nonce hydration tweaks)

Two load-bearing details that are easy to break in the port:

  1. Headers MUST attach in the middleware onRequest hook against the H3 native event, via setResponseHeader(nativeEvent, k, v). SolidStart's onBeforeResponse hook cannot mutate response headers, its second argument is { body? }, not the Response, so headers set there silently never ship and the page falls back to whatever weaker static CSP is lying around.
  2. The per-request nonce must reach hydration via createHandler(..., (event) => ({ nonce: event.locals.nonce })) in entry-server.tsx, and components read it with useNonce().

Verify: Run the dev server and curl it. The response carries the full header set and the CSP nonce matches the inline <script nonce="..."> in the body. One dev-server quirk to know: preview_stop can leave an orphaned vinxi process on port 3000, so a restarted server silently lands on 3001. Kill orphans with:

lsof -ti tcp:3000 | xargs kill

Step 3: Wire Cloudflare Env Access

All secrets and bindings come through nativeEvent.context.cloudflare.env, never process.env, which does not exist in the Workers runtime the way Node code expects. Copy the helper at src/lib/env.ts from the reference repo and route every env read through it.

Verify: Grep the repo for direct process.env reads in server code:

grep -rn "process.env" src/

Expected: no hits in request-handling code paths.

Step 4: Contact Form, Resend, and Turnstile

Wire the contact form to the Resend API. Until the client's domain is verified in Resend, emails send from onboarding@resend.dev, so plan the domain verification early and run the email authentication rollout once DNS is in your hands.

Add Cloudflare Turnstile to the form, and respect the conditional-render gotcha, because it bites on every site that has a multi-step or modal form:

Turnstile's api.js runs a one-time implicit scan for .cf-turnstile elements the moment it loads. It does not observe later DOM additions. A widget inside a SolidStart <Show>, a multi-step form, or a modal that is not in the DOM at script load never renders: no hidden cf-turnstile-response input gets created, and the token check fails with "Please complete the verification challenge." A widget that is always present at load, like a plain contact form, works fine implicitly.

For any conditionally rendered widget:

  • Render explicitly with window.turnstile.render(el, { sitekey, theme }) from a ref callback that fires when the container mounts, polling until the async window.turnstile global exists
  • Drop the cf-turnstile class from the element so the implicit scan cannot also create a duplicate
  • On error, reset(widgetId) the specific widget

This was hit and fixed on the valkyrienexus.com /book BookingForm (PR #24, 2026-06-08), the widget was inside the step-3 <Show>.

Verify: Submit the form end to end in a browser, including any multi-step path, and confirm the email arrives. Then reload and abandon at the Turnstile step to confirm the widget actually rendered there.

Step 5: Image Discipline

Two rules, both because of the 3 MB Worker bundle limit:

  1. Convert every raster to WebP before committing. A committed multi-MB PNG will silently fail a Cloudflare build later.
  2. Know the Nitro bundling trap: images in public/ get base64-bundled into server chunks at build time, which blows past 3 MB fast. The workaround in scripts/deploy.sh moves images out before the build and copies them into .output/public/ after.

Conversion one-liner for the standard thumbnail size:

sips -z 815 1280 in.png --out t.png && cwebp -q 82 -m 6 -sharp_yuv t.png -o out.webp

Verify: Check total raster weight in public/ before the first deploy:

du -ch public/**/*.png public/**/*.jpg public/**/*.webp

Expected: nothing in the multi-MB range, and a successful build under the 3 MB limit.

Step 6: Deploy

Two paths:

  • Workers Builds Git integration (preferred where set up): connect the repo in the Cloudflare dashboard under Workers & Pages → the worker → Settings → Builds. A push or merge to main then auto-builds and deploys, no script, no wrangler login. Build config lives in the dashboard, not the repo.
  • scripts/deploy.sh fallback: the manual build-and-deploy script, which also implements the Nitro image workaround from Step 5.

Verify: The site answers on its production hostname over HTTPS with a 200, and the build log shows the bundle under 3 MB.

Step 7: Post-Deploy CSP Verification

This is the only reliable check that the Worker CSP is actually attaching in production. Fetch headers twice:

DOMAIN=example.com
curl -sI "https://$DOMAIN/" | grep -i content-security
curl -sI "https://$DOMAIN/" | grep -i content-security

The CSP nonce-... value must:

  1. Equal the inline <script nonce="..."> value in the HTML body of the same request
  2. Rotate on every request (the two curls show different nonces)

Red flag: the CSP header shows 'unsafe-inline' in script-src while the HTML carries per-request <script nonce> values. That means the Worker CSP is not attaching and a Cloudflare Transform Rule or static CSP is masking it. Fix the attach path in the middleware and delete any CSP Transform Rule on the zone, security headers live in code, never in Transform Rules.

To compare header and body in the same request, fetch the body once and check both:

curl -sD - "https://$DOMAIN/" -o /tmp/body.html | grep -i content-security
grep -o 'nonce="[^"]*"' /tmp/body.html | head -3

Verify: Header nonce equals body nonce, nonce rotates between requests, no 'unsafe-inline' in script-src.

Step 8: Zone Hardening and Email Authentication

The launch is not done at first deploy. Run the two follow-up runbooks in order:

  1. Cloudflare Zone Hardening - TLS minimums, cipher suites, Always Use HTTPS, dashboard HSTS off, CAA records, full verification script
  2. Email Authentication: SPF, DKIM, DMARC Rollout - pick the sender profile, publish records, start the DMARC ladder at p=none

Verify: Both runbooks' own verification steps pass on the new domain.

Rollback

  1. Bad deploy via Workers Builds - revert the offending commit or merge on main and push. The Git integration auto-builds and deploys the reverted state.
  2. Bad manual deploy - check out the last known-good commit and re-run scripts/deploy.sh.
  3. Build failing on bundle size - the usual culprit is a committed raster or the Nitro image bundling trap from Step 5. Convert to WebP, or confirm the deploy script's move-out/copy-back image step ran.
  4. Blank or broken pages right after the middleware port - strict-dynamic CSP blocking an un-nonced inline script is the first suspect. Check the browser console for CSP violations and the /api/csp-report receiver, then confirm the createHandler nonce wiring in entry-server.tsx.

Maintenance Notes

  • Testimonial carousels: use container.scrollTo(), not scrollIntoView(), which breaks scroll-snap. This applies to every client site.
  • The Turnstile explicit-render pattern from Step 4 applies to every client site, same widget pattern everywhere. Audit any new <Show>-wrapped or modal form for it.
  • Selected Work thumbnails for the .com gallery are WebP 1280×815 in apps/com/public/work/, generated with the sips + cwebp one-liner from Step 5.
  • Re-run the Step 7 CSP check after any change to the middleware, entry-server.tsx, or app.config.ts, those are the three files that can silently detach the nonce.
  • When porting the middleware to the next site, diff against AAFireworks rather than the most recent port, it stays the canonical copy.