DEV Community

Sohana Akbar
Sohana Akbar

Posted on

Docker Caching Strategies That Actually Work with npm ci

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)