DEV Community

Cover image for Why We Ditched npm install in Production (And You Should Too)
Sohana Akbar
Sohana Akbar

Posted on

Why We Ditched npm install in Production (And You Should Too)

Two years ago, we made a change that cut our deployment failures by 80%.

It wasn't a fancy new architecture. It wasn't a microservices overhaul. It was a simple switch from npm install to npm ci --omit=dev in our production Docker containers.

That single change saved us from 4 dependency-related incidents in the first year alone. Here's why it matters, how it works, and why your team should make the switch today.

The Problem: npm install is a Liability in Production
Let's be honest—npm install is great for local development. It's flexible, forgiving, and gets the job done. But in production? It's a ticking time bomb.

Here's what can (and did) go wrong:

  1. The Package Lock That Wasn't npm install doesn't respect your package-lock.json the way you think it does. If your lock file is out of sync with your package.json, npm will update dependencies behind your back.

We once had a patch release of a logging library introduce a breaking change that crashed our entire API fleet. The lock file said one version. The container installed another.

  1. The "It Works On My Machine" Nightmare Local installs, CI installs, and production installs can all yield different dependency trees. Different npm versions, different registry responses, different caching behavior—it's chaos.

We spent 3 days debugging a staging vs. production discrepancy that turned out to be a transitive dependency with a slightly different semantic versioning resolution.

  1. The Time Tax npm install is slow. It checks versions, resolves conflicts, and installs development dependencies you'll never use in production. Every minute spent installing is a minute your deployment is vulnerable.

The Solution: npm ci + --omit=dev
The npm team gave us a production-ready alternative years ago, yet so many teams still sleep on it.

What npm ci Does
Strictly respects package-lock.json: If the lock file doesn't match package.json, npm throws an error and fails the build. No silent updates.

Installs from the lock file only: It skips dependency resolution entirely, making it significantly faster.

Removes node_modules first: Guarantees a clean, reproducible installation every time.

What --omit=dev Does
Excludes devDependencies: Those testing frameworks, linters, and build tools? They don't belong in production.

Prevents accidental imports: If a developer mistakenly imports a dev-only package in production code, the error surfaces immediately.

The Winning Combo
dockerfile

Before (Dangerous)

RUN npm install

After (Production-Ready)

RUN npm ci --omit=dev
That's it. One line change. Massive impact.

The Numbers: 4 Incidents We Avoided
Here's exactly what we escaped by making the switch:

Incident #1: The Patch Release Apocalypse
A minor patch update to axios changed how it handled certain headers. npm install pulled it in automatically. The new behavior broke our auth middleware. Downtime: 47 minutes.

With npm ci? The lock file would have pinned the exact version. The patch wouldn't have been installed until we intentionally updated it in a PR.

Incident #2: The Dev Dependency Security Scare
A popular dev tool with a critical CVE was installed as a devDependency but also pulled in production code through an ill-advised import. Our security scanner flagged it. We scrambled to remove it.

With --omit=dev? It would never have been installed in production. No flag, no panic, no fire drill.

Incident #3: The Build Cache Corruption
Our CI pipeline cached node_modules to speed up builds. A corrupted cache interacted with a fresh npm install to produce a broken build. The fix required invalidating caches and redeploying. Time lost: 2 hours.

With npm ci? It removes node_modules before installing, guaranteeing a clean state every time. No cache contamination.

Incident #4: The Inconsistent Staging
Staging and production used different npm versions. The staging install worked. Production install failed. We wasted a full sprint day diagnosing a mismatch that boiled down to a peerDependency resolution difference.

With npm ci? The lock file ensures identical trees regardless of npm version. No more "but it worked in staging."

Why Teams Resist (And Why They're Wrong)
I've heard every objection:

"But npm install is more flexible!"
Flexibility is a bug in production. We want predictability, not flexibility.

"We'll update the lock file anyway."
You will. But npm ci forces the issue. It doesn't let you drift.

"It's just one more thing to remember."
Put it in your Dockerfile. Put it in your CI scripts. Make it the default. You shouldn't have to remember—it should be automatic.

How to Migrate Today
Step 1: Update Your Dockerfile
dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
COPY package-lock.json ./

The magic line

RUN npm ci --omit=dev

COPY . .

CMD ["node", "server.js"]
Step 2: Update Your CI/CD Pipeline
yaml

GitHub Actions example

  • name: Install dependencies run: npm ci --omit=dev Step 3: Audit Your devDependencies Make sure nothing you actually need in production is accidentally flagged as a dev dependency. Move critical packages to dependencies.

Step 4: Add a Precommit Hook
json
{
"scripts": {
"precommit": "npm ci --omit=dev"
}
}
This catches discrepancies before they ever reach production.

The Results 2 Years Later
Zero dependency-related incidents in the last 18 months.

Faster builds: Our production containers now install in ~40 seconds instead of ~90 seconds.

Peace of mind: We know exactly what version of every package is running in production.

Audit confidence: Security scans are clean because dev-only packages aren't installed.

Don't Wait for an Incident
If you're still using npm install in production containers, you're running on borrowed time. It's not if something will break—it's when.

Make the switch to npm ci --omit=dev. It's free. It's simple. And it might just save your next deployment.

Have you made the switch? Still hesitating? Drop your questions in the comments—let's talk about production npm practices

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY

I completely agree with making npm ci the default for production builds. Reproducible installs, failing fast when the lockfile is out of sync, and avoiding unnecessary dependency resolution are all strong reasons to prefer it over npm install in CI/CD and production containers.

One small nuance I’d add is that some of the problems described are really about lockfile discipline rather than npm install itself. Modern npm versions generally respect package-lock.json when it’s present and in sync. The biggest advantage of npm ci is that it enforces reproducibility by refusing to proceed when the lockfile and package.json diverge, instead of trying to reconcile them.

I’d also recommend taking reproducibility one step further: pin the Node.js and npm versions (or use Corepack for package manager version pinning), build immutable Docker images, and promote the same image across environments instead of rebuilding for staging and production. That eliminates another class of “works in staging but not in production” issues.

Overall, npm ci isn’t just faster—it’s a great way to make deployments deterministic, and deterministic deployments are one of the foundations of reliable production systems.