The local=prod parity checklist (never fail a deploy after green tests)
617 green tests. Red deploy. Users already downloading the app. My git history contains three
separate commits that exist purely to "unblock Vercel build": strict tsc flags the CI
build ran that my local scripts didn't, null-assertions the stricter build rejected. Local
green ≠ prod green, and the gap always announces itself at the worst moment.
The checklist
- Run the EXACT prod build command in pre-push. Not "npm test," not "tsc --noEmit with my flags," but the literal command your deploy platform runs:
# .husky/pre-push
npm run build # the same script Vercel executes, same tsconfig, same flags
- One tsconfig to rule them all. The classic gap: local dev uses a loose config (or
ts-node/vitest's transpile-only mode, which doesn't type-check at all), while the prod build runs stricttsc. Make the strict config the only config, and make tests run against it. - Pin the toolchain. Node version (
engines+.nvmrc), package manager, lockfile-only installs (npm ci). A minor TypeScript version bump on the build server can reject code your local version accepted. - Treat "unblock build" commits as a smell counter. Every commit whose message is basically "make CI happy" is one parity gap you're paying for at deploy time. Three of those = time to fix the pipeline, not the symptoms.
- Fail fast locally on the same warnings. If the platform build treats warnings as errors (strict null checks, unused vars), your editor and pre-push must too. Discovering strictness at deploy is the whole disease.
- Add a deploy-ignore rule for unrelated changes. My
vercel.jsonignoreCommandskips backend deploys when only UI/Flutter files changed. That cut deploy noise ~70%, which also means every red deploy is now a real signal, not churn:
{ "ignoreCommand": "git diff --quiet HEAD^ HEAD -- ./backend" }
- Keep a deploy smoke test. After green build: curl the health endpoint, then one authenticated route. Green build + hung runtime is its own failure class (ask me about Fluid Compute).
- Rehearse the rollback. When a red deploy does slip through with users live, the fix is "promote previous deployment," not "debug under fire." Know where that button is before you need it.
Steal this for your app
- Ask of every pipeline stage: "what does the deploy machine know that my laptop doesn't?" Every difference (flags, versions, env vars, clean checkout) is a future red deploy.
- The pre-push hook feels slow the first week. It's still ~100× faster than a failed deploy round-trip with users watching.
- This generalizes past TypeScript: Docker builds, migrations, asset pipelines. Parity is a property you enforce, not a default you get.
Run this on your codebase
Paste this into Claude Code in your repo:
Audit this repo for local vs prod build parity gaps.
Compare the local test and build scripts in package.json to the exact command the deploy platform runs (vercel.json, CI workflow files, Dockerfiles).
List every strict flag or check that CI/prod runs and local dev skips, including transpile-only test runners that never type-check.
Compare tsconfig files and flag any looser local config that diverges from the build config.
Check toolchain pinning: engines, .nvmrc, package manager, lockfile-only installs (npm ci).
Search git log for "unblock build" or "fix CI" style commits and count them as parity smells.
Propose a pre-push hook that runs the exact prod build command.
Report findings as a checklist before changing anything.
The container path: test the artifact that ships
A Dockerfile closes the biggest parity gap when it owns the runtime, system packages, install command, build command, and start command. Build it from a clean context in CI, run the tests inside it, then deploy that exact image digest. Do not rebuild from the same Dockerfile after CI and call it the same artifact.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm test && npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app ./
CMD ["npm", "start"]
Use Compose to reproduce required services and environment names locally. Keep secrets outside the image, add a health check, and rehearse starting the exact image with production-like settings.
The semantic-versioning path: same commit, different tree
R091's overnight failure happens when a version range resolves during a clean install. A caret such
as ^2.1.0 grants permission to install later compatible releases. It is not the exact dependency
tree that worked yesterday.
Commit the lockfile and use the package manager's frozen install command (npm ci,
pnpm install --frozen-lockfile, or the equivalent). Fail CI when a manifest changes without its
lockfile. Log the runtime and package-manager version with the build. Upgrade dependencies in a
named change with a tree diff and rollback, not as a side effect of deployment.
Minor is a promise, not a guarantee. The lockfile is the receipt.