npm Scripts for Production Deploys: Lifecycle Hooks, npm ci, Lockfiles, and Builds You Can Reproduce
How to structure npm scripts for production: which lifecycle hooks fire when, why npm ci beats npm install in CI, what a lockfile mismatch error actually looks like, and how workspaces change the build command.
npm Scripts for Production Deploys: Lifecycle Hooks, npm ci, Lockfiles, and Builds You Can Reproduce
A deploy script that works on your laptop and fails in CI almost always fails for one of four reasons: a lifecycle hook you didn't know was firing, an environment variable that exists locally but not on the runner, npm install quietly rewriting your dependency tree, or a lockfile that drifted out of sync with package.json. This post walks through each one with the actual commands and the actual error output, so the next 2am deploy failure takes five minutes instead of an hour. For the full command reference with flags and yarn/pnpm equivalents, keep the npm cheat sheet open in another tab.
Lifecycle hooks: what actually runs, and in what order
npm runs pre<name> and post<name> scripts automatically around any script you invoke. If your package.json has:
{
"scripts": {
"prebuild": "node scripts/check-env.js",
"build": "astro build",
"postbuild": "node scripts/generate-sitemap.js"
}
}
then a single npm run build executes all three, in order. The output makes the chain visible:
$ npm run build
> toolora-web@1.0.0 prebuild
> node scripts/check-env.js
> toolora-web@1.0.0 build
> astro build
> toolora-web@1.0.0 postbuild
> node scripts/generate-sitemap.js
The hooks that bite people in production are the install-time ones. postinstall runs after every npm install — including on your CI runner and inside your Docker build. prepare runs after local installs and before npm publish, which is why it's the standard place for husky setup, and why that same husky command crashes a production image that has no .git directory. Two defenses: guard the script ("prepare": "husky || true") or install with --ignore-scripts in environments where you don't need hooks at all. The second option is also a supply-chain safety net, since a compromised transitive dependency's postinstall is the classic attack vector.
Every field you can put in scripts — plus engines, overrides, and the rest of the manifest — is covered in the package.json cheat sheet if you want the full map.
Environment variables: npm gives you some, the rest are your job
npm injects its own variables into every script. npm_lifecycle_event holds the name of the running script, and every package.json field is exposed as npm_package_*:
{
"scripts": {
"version:print": "node -e \"console.log(process.env.npm_package_version)\""
}
}
$ npm run version:print
1.4.2
That's handy for stamping a build with its version without parsing JSON in shell. What npm does not do is read .env files — that's your framework or a loader like dotenv doing it, and only if it's wired in. A deploy script that relies on .env being auto-loaded works locally (because Vite or Next reads it) and then produces undefined in a bare node scripts/deploy.js. Before blaming the platform, check the file itself: I run env files through the dotenv validator to catch duplicate keys and unquoted values with spaces, which are the two mistakes that survive code review because the file is gitignored and nobody else ever sees it.
One more portability trap: BUILD_TARGET=prod npm run build sets the variable on Linux and macOS but is a syntax error on Windows shells. If anyone on the team deploys from Windows, use cross-env in the script instead of inline assignment.
npm ci vs npm install: one of these belongs in your deploy pipeline
npm install is a development command. It resolves ranges, updates package-lock.json if it can, and keeps whatever is already in node_modules. npm ci is the deployment command: it deletes node_modules, installs exactly what the lockfile says, never writes to package.json or the lockfile, and hard-fails if the two files disagree. When npm introduced ci in 2018, their own announcement benchmarked it at roughly 2x faster than npm install in CI environments, largely because it skips dependency resolution entirely (npm blog, "Introducing npm ci", 2018).
The failure mode is loud and specific. Bump a dependency in package.json without regenerating the lock, and npm ci says:
$ npm ci
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json are in sync. Please update your lock file
npm error with `npm install` before continuing.
npm error
npm error Invalid: lock file's zod@3.23.8 does not satisfy zod@^4.0.0
That error is a feature. The same drift under npm install would have been silently "fixed" on the runner, and your production build would use a dependency tree that exists nowhere in git. One caveat carried over from the hooks section: since npm 7, npm ci runs prepare and postinstall scripts too, so --ignore-scripts still matters even on the strict path.
Lockfiles and workspaces: the reproducibility layer
The lockfile only guarantees reproducibility if you treat it as source code: commit it, review its diffs, and never let CI regenerate it. Two settings make the guarantee stronger. save-exact=true in .npmrc stops new dependencies from getting ^ ranges at all, and an engines field plus engine-strict=true fails the install when the runner's Node version doesn't match — catching the "works on Node 20, breaks on the box's Node 18" class of bug at install time instead of at runtime. When I audited Toolora's own lockfile after a dependabot week, I used the package-lock dependency auditor to see which of the 40-odd changed entries were direct dependencies and which were transitive noise; reviewing a 3,000-line lockfile diff by hand is not a thing anyone actually does.
Workspaces change the shape of your deploy scripts. In a monorepo, npm run build at the root only runs the root's script. To fan out:
npm run build --workspaces --if-present # every package that has a build script
npm run build -w apps/web # just one workspace
--if-present is the important flag — without it, the first package lacking a build script kills the whole run. The semantics differ across package managers (pnpm uses -r and filters), so if your monorepo is on pnpm, the pnpm workspace cheat sheet has the equivalent commands.
A deploy script skeleton worth copying
Putting all four sections together, a minimal production build that fails fast and reproduces exactly:
{
"engines": { "node": ">=20 <21" },
"scripts": {
"predeploy:build": "node scripts/check-env.js",
"deploy:build": "npm ci --ignore-scripts && npm run build --workspaces --if-present"
}
}
Strict Node range, environment validated before anything expensive runs, lockfile enforced by npm ci, install hooks disabled, and a workspace-aware build. It's four lines, and it eliminates the four failure classes this post started with. The rest — cache flags, --omit=dev for runtime images, publish workflows — is on the npm cheat sheet when you need it.
Made by Toolora · Updated 2026-07-02