Logo

[Node.js] Upgrading from Node 20 to 24 in a Dockerized Next.js App

6 min read
Node.jsDockernpmNext.jsCI/CD

Table of Contents

Why Upgrade?

Node.js 20 hits EOL on April 30, 2026. Node 24 is the current Active LTS ("Krypton") with support until April 2028. Seemed like a good time to jump.

I went from Node 20.20.0 straight to 24.14.0, upgrading one major version at a time (20 → 22 → 24) to isolate any issues.

What Needed to Change

The straightforward part — updating version references everywhere:

Node 22 was a clean upgrade. Node 24 is where things got interesting.

Issue 1: npm prune --production is Dead

npm 9+ deprecated --production. npm@11 (bundled with Node 24) still accepts it but throws warnings. The fix:

1# Before
2RUN npm prune --production
3
4# After
5RUN npm prune --omit=dev

Simple enough. But this change also introduced Issue 3 (below).

Issue 2: --ignore-scripts Now Blocks Everything

This was the big one.

I wrote about using --ignore-scripts for security a while back. It's great for preventing supply chain attacks — skip all lifecycle scripts during install, then selectively rebuild trusted packages.

In npm@10 and below, --ignore-scripts skipped preinstall and postinstall but still ran prepare. In npm@11, it blocks everything, including prepare.

Why does this matter? I have an internal icon library (@myorg/icons) installed as a git dependency. It uses a prepare script to build itself:

1{
2 "scripts": {
3 "prepare": "npm run build",
4 "build": "npm run optimize && node ./lib/build.js"
5 }
6}

With npm@10, npm ci --ignore-scripts would skip postinstall but still run prepare, so the icon library would build its dist/ directory. With npm@11, prepare is blocked too, so dist/ is never generated and the build fails:

1Type error: Module '"@/components/UI/Icon"' has no exported member 'IconSample'.

What Didn't Work

npm rebuild — I tried adding the package to the existing rebuild command:

1npm ci --ignore-scripts && npm rebuild dd-trace @myorg/icons

But npm rebuild only runs native addon scripts (install, postinstall), not prepare. So the icon library still wasn't built.

Running the build script directly — Next attempt:

1npm ci --ignore-scripts && npm rebuild dd-trace && npm run --prefix node_modules/@myorg/icons build

This failed because the package's build script needs svgo, which is a devDependency of the icon library. Since --ignore-scripts prevented the full install, svgo wasn't available:

1sh: 1: svgo: not found

Also, for git-based dependencies, npm doesn't keep the source files (src/, lib/) after install — only the build output. So even if svgo was available, the source files to build from weren't there.

What Worked

Reinstall the package individually without --ignore-scripts:

1RUN --mount=type=cache,target=/root/.npm \
2 npm ci --ignore-scripts && npm rebuild dd-trace && \
3 npm cache clean --force && npm install @myorg/icons

The npm install @myorg/icons runs without --ignore-scripts, so prepare executes normally and dist/ is generated.

Since this is an internal private package (not a random npm package from the internet), running its scripts doesn't compromise the security posture that --ignore-scripts was set up for.

Issue 3: npm prune Needs Git Auth in Multi-Stage Builds

After fixing the icon library, the Docker build failed at a different stage:

1npm error command git --no-replace-objects ls-remote ssh://git@github.com/myorg/icons.git
2npm error git@github.com: Permission denied (publickey).

This happened during npm prune --omit=dev in the builder stage. npm@11 apparently validates git dependencies during prune, which means it needs access to the git remote.

The problem: my Dockerfile is a multi-stage build. GitHub credentials (.netrc) are set up in the deps stage but not in the builder stage. With npm@10, prune didn't try to resolve git deps. With npm@11, it does.

The fix — copy .netrc from the deps stage:

1FROM node:24.14.0-slim AS builder
2RUN apt-get update && apt-get install -y git
3WORKDIR /app
4COPY --from=deps /root/.netrc /root/.netrc # npm@11 needs git auth for prune
5COPY --from=deps /app/node_modules ./node_modules
6COPY . .

This is safe because the builder stage is intermediate — it's discarded after the build. The final production image (distroless) only copies specific app files and never includes .netrc.

Bonus: Node 24 Deprecation Warning

After deploying, I noticed this in the logs:

1(node:7) [DEP0187] DeprecationWarning: Passing invalid argument types to fs.existsSync is deprecated

This is from dd-trace, not my code. It passes undefined to fs.existsSync somewhere (likely checking process.env.DD_TELEMETRY_FORWARDER_PATH when it's not set). This was already fixed in dd-trace 5.x but my version (5.24.0) predates the fix. It's just a warning — harmless for now.

The Final Dockerfile

Here's what the relevant parts look like after all fixes:

1FROM node:24.14.0-slim AS deps
2# ... auth setup ...
3RUN npm ci --ignore-scripts && npm rebuild dd-trace && \
4 npm cache clean --force && npm install @myorg/icons
5
6FROM node:24.14.0-slim AS builder
7RUN apt-get update && apt-get install -y git
8WORKDIR /app
9COPY --from=deps /root/.netrc /root/.netrc
10COPY --from=deps /app/node_modules ./node_modules
11COPY . .
12# ... env vars ...
13RUN npm run build
14RUN npm prune --omit=dev
15
16FROM gcr.io/distroless/nodejs24-debian12 AS runner
17# ... only app files copied here, no credentials ...

Version Choice

I went with 24.14.0 instead of 24.14.1 (the latest security patch released the day before). Distroless images and Docker tags can take a day or two to stabilize after a new release, so I opted for the slightly older version that I could verify had working Docker images.

TL;DR

Upgrading Node.js in a Dockerized app isn't just bumping version numbers. npm@11 (bundled with Node 24) has breaking changes around --ignore-scripts and dependency resolution that can silently break multi-stage Docker builds. The three issues I hit:

  1. npm prune --production → use --omit=dev
  2. --ignore-scripts blocks prepare → reinstall git-based packages individually
  3. npm prune validates git deps → forward credentials to the builder stage

Each one only showed up in CI/CD Docker builds, not locally. Test your Docker builds early.

References

Docker Images

Related Articles