If you’ve ever waited 10 minutes for npm ci to run inside a Docker build, you know the pain.
You add a single line of code, rebuild, and… Docker re-installs everything. 🔥
Here’s the truth: npm ci is fantastic for CI/CD—but its strict nature breaks naive Docker caching. Let’s fix that for good.
Why npm ci breaks your cache
npm ci installs directly from package-lock.json. If the lockfile changes at all, Docker’s build cache invalidates from that layer onward.
But Docker doesn’t just check the content of package-lock.json—it checks the file’s timestamp too. A fresh git checkout? New timestamp → cache miss.
Strategy 1: Copy only the lockfile first
❌ Don’t do this:
dockerfile
COPY . .
RUN npm ci
✅ Do this:
dockerfile
COPY package*.json ./
RUN npm ci
COPY . .
This way, npm ci only reruns if package.json or package-lock.json actually changes.
Strategy 2: Leverage Docker’s --mount=type=cache (modern hero)
For Docker 18.09+ with BuildKit:
dockerfile
syntax=docker/dockerfile:1.4
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
COPY . .
The --mount=cache keeps npm’s global cache between builds. Combine with --only=production for smaller images.
Strategy 3: Separate devDependencies from production
dockerfile
Stage 1: dev dependencies (for testing)
FROM node:20-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
Stage 2: production-only
FROM node:20-slim AS prod
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=deps /app/node_modules /app/node_modules
COPY . .
Why this works: --only=production creates a deterministic subset. Changes to dev dependencies don’t touch production layers.
Strategy 4: Copy lockfile with preserved timestamps
Some CI systems (looking at you, Jenkins) reset timestamps. Fix it:
dockerfile
COPY --chown=node:node package*.json ./
Force a known timestamp if needed:
RUN touch --date="2024-01-01 00:00:00" package-lock.json
Better: Use --checksum with BuildKit (available in newer Docker):
dockerfile
COPY --checksum=sha256:... package-lock.json .
The ultimate recipe (for most projects)
dockerfile
syntax=docker/dockerfile:1.4
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
Final stage
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
CMD ["node", "index.js"]
Real-world results
Before: 3–5 min installs on every build
After: 10–20 secs (cache hit), 2 min (full rebuild)
Gotchas to remember
npm ci deletes node_modules before install. That’s intentional—don’t fight it.
Private registries? Mount your .npmrc separately:
dockerfile
COPY .npmrc ./
RUN --mount=type=secret,id=npmrc cat /run/secrets/npmrc > .npmrc
Always run npm ci (not npm install) in CI. You want exact lockfile compliance.
Bottom line
Treat package-lock.json as the source of truth. Copy it first. Use BuildKit’s cache mounts. Separate dev from prod. Your build times will thank you.
Have a faster trick? Let me know in the comments. 🚀
Top comments (0)