RE:NODE
Обзор хостинга

Приложения12 мин чтения

npm ci vs npm install: which one belongs in a deploy

What npm ci does differently, why the lockfile must be committed, how to keep devDependencies for the build and drop them after it.

Эта статья пока на английском. Мы её переводим.

0 прочтений

Use npm install on your own machine when you are changing dependencies. Use npm ci everywhere a machine installs on your behalf - your server, a build step, CI. The difference is that npm install is allowed to change your dependency tree and npm ci is not: it deletes node_modules, installs exactly what package-lock.json says, and fails loudly if the lockfile and package.json disagree. That single property is why the version you tested is the version that runs. Everything below is what follows from it, including the part that catches everyone - the build step that needs the dev dependencies you were trying to leave out.

What each command actually does#

npm installnpm ci
Needs a lockfileNoYes, or it exits
Writes the lockfileYes, when it mustNever
Existing node_modulesReuses and patchesDeleted first
Package version rangesRe-resolves ^ and ~Ignores them, installs the tree
Out-of-sync package.jsonFixes it silentlyFails with EUSAGE
Install one packagenpm install lodashNot supported
Typical speedSlowerOften around twice as fast

npm install reads package.json, sees ranges like "express": "^4.18.2", and works out a tree that satisfies them. If a lockfile exists and its versions still satisfy those ranges, it uses them. If they do not - because someone edited package.json by hand, or a dependency was added on another branch - it resolves fresh and rewrites the lockfile. Helpful on a laptop. On a server it means your deploy quietly installed something you have never run.

npm ci does no resolution at all. The lockfile already contains the full tree: every package, its exact version, its resolved URL and an integrity hash. npm ci hydrates that tree and stops. It is deterministic in the strong sense - the same lockfile gives the same node_modules on any machine, on any day, whatever has been published since.

A side effect worth knowing: ERESOLVE peer-dependency errors are an npm install phenomenon. They happen during resolution, and npm ci does not resolve. If you had to run npm install --legacy-peer-deps locally, the resulting tree is already baked into the lockfile, and the server does not need the flag.

Why the lockfile decides everything#

package-lock.json is not a build artefact. It is source code, and it goes in git. The single most common cause of "it worked in development" is a .gitignore that contains package-lock.json, usually copied from a library template where excluding it is defensible. For an application it never is.

Inside, each entry looks roughly like this:

json
"node_modules/express": {  "version": "4.18.2",  "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",  "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",  "dependencies": { "accepts": "~1.3.8" }}

The integrity hash is what makes a reproducible install a security property and not just a convenience: if the tarball at that URL ever changes, the install fails rather than running different code under the same version number.

The header field lockfileVersion tells you which npm wrote it:

VersionWritten byNotes
1npm 6No full tree metadata; newer npm upgrades it
2npm 7 and 8Contains both formats, so npm 6 can still read it
3npm 9 and 10 for new projectsSmaller, needs npm 7 or later

Mixing npm versions across a team is how you end up with a 4,000-line lockfile diff on a pull request that changed one dependency. Pin the runtime instead: an .nvmrc file, and an engines block that says what you support.

package.json
{  "engines": { "node": ">=20.0.0", "npm": ">=10.0.0" }}

By default engines is advisory. Add engine-strict=true to .npmrc to make a mismatch an error, which is the right call for a project where the server's Node version is fixed and your laptop's is not.

devDependencies, builds and the trap in the middle#

--omit=dev (the modern spelling of the deprecated --production) skips everything in devDependencies. On a server that is what you want: no TypeScript compiler, no test runner, no bundler, hundreds of megabytes less on disk.

Except that typescript, vite, webpack, esbuild, tailwindcss and prisma are all dev dependencies, and all of them are needed to produce the thing you are about to run. So this fails:

bash
$ npm ci --omit=dev$ npm run buildsh: tsc: not found

There are two correct sequences, and which one you want depends on whether the build happens on the same machine as the run.

Build on the server. Install everything, build, then prune:

bash
$ npm ci --no-audit --no-fund$ npm run build$ npm prune --omit=dev

npm prune --omit=dev removes the dev packages from the existing node_modules without touching anything else. It is the step people leave out, and it is usually worth several hundred megabytes.

Build elsewhere. If a CI job or your laptop produces the built output and ships it, the server only ever needs runtime packages:

bash
$ npm ci --omit=dev --no-audit --no-fund$ node dist/server.js

Do not rely on NODE_ENV=production to omit dev dependencies for you. That behaviour has differed between npm versions, and a deploy that depends on an environment variable's side effect is a deploy that breaks on an upgrade. Pass the flag.

One more consequence of omitting dev dependencies: the prepare lifecycle script does not run. Projects that build themselves in prepare - common with Husky, and with packages installed straight from a git URL - will silently produce nothing. If your install depends on prepare, you need the full install.

The deploy sequence that works#

Put in order, a Node deploy on a small server looks like this:

  1. Fetch the code, including package-lock.json. If the lockfile is missing, stop and fix that before anything else.
  2. `npm ci` with dev dependencies, if the build happens here.
  3. Build - npm run build, or tsc, or whatever produces dist.
  4. Run migrations if the release needs them, and make them backwards compatible so the old process can still serve requests while the new one starts. Migrations without downtime is the long version.
  5. `npm prune --omit=dev` to shed the build tools.
  6. Restart the process and watch the log until it reports listening.

On RE:NODE the first step is the Git deploy feature on app plans: GitHub through a GitHub App with short-lived tokens, so private repositories work without you pasting a personal access token anywhere. Two switches control it - pull on every start, and deploy on push, which restarts a server only if it was already running - and each deploy leaves a record. Deploying a Node app from GitHub walks through the setup.

The failure this sequence is designed to prevent is the crash loop. If the install fails, the process exits, something restarts it, the install fails again. On RE:NODE a watcher notices: three restarts in an hour puts a warning on the server page and opens a ticket automatically, and six suspends the server. That is a deliberate brake rather than a punishment, but it is much easier if step 2 fails once, visibly, in a console you are reading. Reading the console covers what to look for.

If a deploy can take the site down, it should be reversible in a minute. Keep the previous build, and take a backup before a dependency bump that touches anything native - zero-downtime deploys on a small server has the switching patterns that fit one machine.

Install time, cache and disk on a small plan#

npm ci deleting node_modules sounds expensive and mostly is not, because the packages themselves come from the local cache at ~/.npm/_cacache rather than the network. A warm cache turns most installs into file copies.

Flags that genuinely help on a small server:

  • --no-audit - skips the vulnerability check that runs at the end of an install. It needs a network round trip and prints a wall of text nobody reads during a deploy. Audit in CI instead.
  • --no-fund - suppresses the funding notice. Cosmetic, but it shortens the log.
  • --prefer-offline - uses cached data where it can and only asks the registry for what is missing. Useful when the registry is slow rather than down.
  • --ignore-scripts - refuses to run install scripts from dependencies. Good for supply-chain safety, but it breaks any package that compiles native code at install time, so test it before adopting it.

Disk is the constraint people meet first. A plain Express API is 50-100 MB of node_modules; a Next.js app with a UI library is commonly 500-800 MB before you have built anything, and the build output and the npm cache sit beside it. On a plan with 5 GB that is not a rounding error. Two habits keep it under control: prune after the build, and clear the cache occasionally with npm cache clean --force - though note that this makes the next install slower, so do it when disk is actually tight, not on a schedule.

Memory is the other one. Installing is cheap; bundling is not. tsc and next build on a large project routinely want more than a gigabyte, and on a 1 GB plan the build is what gets killed, not the app. On RE:NODE, reaching the memory limit stops the container and restarts it clean instead of letting it swap, so the symptom is a build that vanishes mid-run with no error. If that is you, either build somewhere else and deploy the output, or move up a tier for the duration. Node memory limits explained covers the heap flags and what they do not fix.

When npm ci refuses, and what the error means#

`npm ci` can only install packages when your `package.json` and `package-lock.json` are in sync. The lockfile does not contain something package.json asks for. Somebody edited package.json directly, or a merge resolved the lockfile badly. Fix it on your machine with npm install, commit the updated lockfile, deploy again. Do not "fix" it by switching the server to npm install - that hides the drift permanently.

`The npm ci command can only install with an existing package-lock.json`. There is no lockfile in the repository. Generate one with npm install, commit it.

Merge conflicts in the lockfile. Never hand-edit. Take either side, then reconcile:

bash
$ git checkout --theirs package-lock.json$ npm install$ git add package-lock.json

npm install with a correct package.json rewrites a consistent lockfile, which is the only way to get one that is actually valid.

`Cannot find module @rollup/rollup-linux-x64-gnu` or a similar platform-specific package. Optional dependencies are resolved per platform, and a lockfile generated on macOS or Windows can omit the Linux binary your server needs. Regenerate the lockfile on Linux - in a container matching the server, or in CI - and commit that. Deleting node_modules on the server does not help, because the missing entry is in the lockfile.

`EBADENGINE`. The installed Node or npm does not satisfy engines. Either the server is on an old runtime or the package is newer than you thought. Check with node -v and npm -v before assuming the package is wrong.

`EACCES` or `EPERM` during install. The process cannot write where it is trying to. On a container-based host this normally means something outside the server's own directory, which is not writable by design.

`ENOSPC`. Out of disk. du -sh node_modules .npm tells you which of the two ate it.

Keeping the lockfile honest#

A lockfile is only useful if it is true. Three habits keep it that way.

One person's machine is not the reference. Run npm ci in CI on every pull request. It fails on a lockfile that was not committed, and it fails on a lockfile that no longer matches package.json - which are the two mistakes that reach production.

Update deliberately. npm outdated shows what has moved. npm update respects your ranges and bumps within them; changing a major version is an edit to package.json followed by npm install. Do one group at a time so a broken deploy has one suspect.

Check what you are actually shipping. npm ls --omit=dev --depth=0 prints the runtime tree. It is a short list, and reading it once a quarter finds packages nobody remembers adding.

For the other package managers the equivalent of npm ci is a flag rather than a command:

ToolReproducible install
npmnpm ci
Yarn 1yarn install --frozen-lockfile
Yarn 2 and lateryarn install --immutable
pnpmpnpm install --frozen-lockfile (the default in CI)
Bunbun install --frozen-lockfile

Whichever you use, commit one lockfile and only one. A repository containing both package-lock.json and yarn.lock will eventually install two different trees depending on who ran what, and the day you find out is the day it matters.

Private registries and scoped packages need credentials, which belong in an environment variable rather than a committed .npmrc. npm expands ${NPM_TOKEN} in .npmrc at read time, so the file can be committed while the value is not - see environment variables and secrets for where that value should live. If you keep a staging server beside production, give it its own copy of every secret; staging and production on one account covers the separation.

FAQ#

Should I commit package-lock.json?

Yes, for any application. It is the record of what you tested. The only defensible exception is a library published to a registry, where consumers resolve their own tree and your lockfile only affects your own development.

Is npm ci faster than npm install?

Usually, often by roughly half, because it skips dependency resolution entirely. The gap is largest with a warm cache and a big tree. Speed is a bonus, though - determinism is the reason to use it.

Can I run npm ci without devDependencies?

Yes, with npm ci --omit=dev, but only if nothing in your build needs them. If you build on the same machine, install everything, build, then run npm prune --omit=dev to remove them afterwards.

What if I do not have a lockfile at all?

npm ci will refuse. Run npm install once on a machine you trust, commit the generated package-lock.json, and use npm ci from then on. Do not generate it on the production server - that defeats the purpose.

Why did my deploy install a different version than my laptop?

Because something ran npm install on the server with a lockfile that did not constrain it, or with no lockfile at all. A caret range like ^4.18.2 accepts any 4.x release, and one of those was published between your test and your deploy.

Does npm ci delete my uploaded files?

It deletes node_modules and nothing else. Anything you keep outside that folder - uploads, a SQLite file, generated config - is untouched. Storing writable data inside node_modules is the only way to get hurt here, and it is worth checking you are not.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000