Back to Initiative Library
Framework Migrations High complexity

Upgrade Node.js Runtime (16 to 20)

✦ Sample Prompt
Move every Node.js service from Node 16 (EOL) to Node 20 (active LTS).

For each repository:
1. Update `package.json`:
   - `"engines": { "node": ">=20.0.0" }`
   - Bump any dependency with a hard Node 20 floor (e.g., `eslint` 9, `vite` 5,
     `typescript` 5.x)
2. Update Dockerfiles:
   - `FROM node:16-*` → `FROM node:20-*` (preserve the variant suffix:
     `-alpine`, `-slim`, `-bookworm`)
   - Apply to every `FROM` in multi-stage builds
3. Update CI workflows in `.github/workflows/*.yml`:
   - `actions/setup-node@v4` with `node-version: '20'`
   - Update matrix entries that pin `16.x` to `20.x`
4. Update `.nvmrc` and `.node-version` files to `20`.
5. Patch known breaking-change call sites:
   - `crypto.createCipher` / `createDecipher` (removed) →
     `createCipheriv` / `createDecipheriv`
   - `url.parse` (deprecated) → `new URL(...)`
   - `fs.rmdir(..., { recursive: true })` → `fs.rm(..., { recursive: true, force: true })`
   - Replace `node-fetch` with the global `fetch` where the codebase no longer
     needs streaming workarounds
6. Run `npm ci && npm test` (or yarn / pnpm equivalent). Surface failing repos in
   a triage list.

The Problem

Node 16 reached end-of-life in September 2023. Every service still on it is unsupported, blocked from current versions of `eslint`, `vite`, `typescript`, and most modern tooling, and accumulating a steady security-vulnerability backlog. Worse, the runtime is encoded in at least four different places per repo (`engines` in `package.json`, the base image in every Dockerfile `FROM`, the `node-version` in every GitHub Actions workflow, and `.nvmrc` / `.node-version` files for local dev) and they're never quite in sync.

Bumping to Node 20 also surfaces a handful of breaking-change call sites: `crypto.createCipher` was removed, `url.parse` is deprecated, `fs.rmdir({ recursive: true })` no longer accepts recursive directories. Most repos have one or two of these; some have a dozen. Finding them, patching them, and verifying the test suite still passes (across hundreds of repos with slightly different Docker layouts and CI patterns) is the kind of tedious cross-cutting work that defeats manual migration efforts.

What Tidra Does

  1. Updates package.json engines.node to >=20.0.0 and bumps any dependency with a hard Node 20 floor (eslint 9, vite 5, current typescript)
  2. Rewrites every FROM node:16-* in Dockerfiles to FROM node:20-*, preserving the variant suffix (-alpine, -slim, -bookworm) across multi-stage builds
  3. Updates .github/workflows/*.yml, actions/setup-node versions, node-version keys, and matrix entries pinned to 16.x
  4. Bumps .nvmrc and .node-version files to 20
  5. Patches known breaking-change call sites (crypto.createCiphercreateCipheriv, url.parsenew URL(...), fs.rmdirfs.rm) and removes node-fetch where global fetch suffices
  6. Runs the test suite per repo and surfaces failing repos in a triage list, leaving complex failures for human review

Before & After

diff
package.json
@@ -3,12 +3,12 @@
"version": "1.4.0",
"engines": {
- "node": ">=16.0.0"
+ "node": ">=20.0.0"
},
"dependencies": {
- "node-fetch": "^2.7.0",
"express": "^4.18.2"
},
"devDependencies": {
- "eslint": "^8.57.0",
- "typescript": "~4.9.5",
- "vite": "^4.5.0"
+ "eslint": "^9.10.0",
+ "typescript": "~5.6.0",
+ "vite": "^5.4.0"
}
}
Dockerfile
@@ -1,5 +1,5 @@
- FROM node:16-alpine AS build
+ FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
- FROM node:16-alpine
+ FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

Customization Tips

  • Target Node version: Node 20 is current LTS; Node 22 is the next active LTS. Pick once for the org. Mixing 20 and 22 across services causes the same drift you're trying to eliminate.
  • Native module rebuilds: Repos using native modules (sharp, node-gyp, bcrypt) may need their lockfiles regenerated for prebuilt binaries that match the new Node ABI.
  • Dependency floor scope: Decide whether to also bump eslint 9, typescript 5.x, and other tools to their current majors as part of the runtime bump, or stage them as follow-ups to keep diffs reviewable.
  • Test coverage gate: Require npm test to pass per repo before merging. Repos with low coverage should also get a manual smoke test of the entry point. Runtime bumps surface latent bugs the tests miss.

Ready to run this across your repos?

Connect your Git provider and Tidra opens pull requests in every repo that needs them.