Post

npm, pnpm and Yarn: Node.js Package Managers in the Build

How npm, pnpm and Yarn install, lock and run Node.js projects, which files they need, how to scan dependencies, and a complete GitHub Actions pipeline.

npm, pnpm and Yarn: Node.js Package Managers in the Build

In the JavaScript world the package manager (npm, pnpm, Yarn, Bun) is the entry point of every build: it installs dependencies from a lockfile, runs the scripts defined in package.json and publishes packages. The actual compile/bundle step is a separate tool, covered in JavaScript Bundlers.

This post is part of Build Ecosystems by Programming Language.

At a glance

CategoryCommon tools
Package managersnpm (bundled with Node.js), pnpm, Yarn (Berry / v4), Bun
Monorepo toolsnpm / pnpm / Yarn workspaces, Nx, Turborepo, Lerna
Registrynpmjs.com, GitHub Packages, private Artifactory / Verdaccio
Build filespackage.json, lockfile, .npmrc, .nvmrc, pnpm-workspace.yaml, .yarnrc.yml
Lint and formatESLint, Prettier, Biome
SAST (open source)Semgrep, CodeQL, eslint-plugin-security, SonarQube Community Build
SAST (commercial)Checkmarx One, Veracode, Snyk Code, SonarQube Server
SCAnpm audit, pnpm audit, OSV-Scanner, Trivy, Snyk Open Source, Socket, Mend, Black Duck
FrameworksExpress, NestJS, Fastify, Next.js
Test and coverageJest, Vitest, Node test runner (node --test), Istanbul / c8
Build outputnpm tarball (.tgz), node_modules + app for container images
CI cache~/.npm, pnpm store, Yarn cache

How the build works

In CI, each stage of the generic build pipeline maps to a npm (or pnpm / Yarn) command, plugin or scanner. Press play, or step through it:

  1. Source + lockfile: A commit triggers CI. package.json and exactly one committed lockfile (package-lock.json, pnpm-lock.yaml or yarn.lock) define the dependency graph.
  2. Install from the lockfile: npm ci (pnpm install --frozen-lockfile, yarn install --immutable) installs exactly the lockfile and fails if it disagrees with package.json.
  3. Lint and format: npm run lint runs ESLint from a package.json script; Prettier or Biome check formatting.
  4. Build: npm run build runs the project's build script, for example tsc or a bundler.
  5. Test and coverage: npm test runs Jest, Vitest or node --test; add --coverage for Istanbul / c8 coverage.
  6. Security scans in parallel: semgrep scan --config p/nodejs checks our code, npm audit --audit-level=high --omit=dev checks production dependencies, and Gitleaks scans for secrets.
  7. Quality gate: Lint, tests, coverage and the audit are required checks. One failure stops the pipeline before anything is packed.
  8. Fail the build (fail): A failed check stops the pipeline at the gate, so nothing is packaged, signed or published.
  9. Package (pass): npm pack builds the .tgz tarball exactly as the registry would receive it.
  10. SBOM and provenance (pass): npx @cyclonedx/cyclonedx-npm writes sbom.json, and npm publish --provenance adds signed build provenance.
  11. Publish (pass): npm publish --provenance --access public uploads to npmjs.com or a private registry; consumers verify with npm audit signatures.
  • Install from the lockfile in CI. npm ci, pnpm install --frozen-lockfile and yarn install --immutable fail if package.json and the lockfile disagree.
  • Layouts differ. npm and Yarn classic hoist packages into a flat node_modules; pnpm uses a content-addressable store with symlinks (strict, no phantom dependencies); Yarn Berry can use Plug’n’Play with no node_modules at all.
  • Install scripts run arbitrary code. preinstall / postinstall scripts of dependencies run on your build agent, which is the main supply-chain risk in this ecosystem.

Same task, three package managers

TasknpmpnpmYarn (v4)
Install from lockfilenpm cipnpm install --frozen-lockfileyarn install --immutable
Add a dependencynpm install expresspnpm add expressyarn add express
Run a scriptnpm run buildpnpm buildyarn build
Auditnpm auditpnpm audityarn npm audit
Why is this installed?npm explain lodashpnpm why lodashyarn why lodash
Publishnpm publishpnpm publishyarn npm publish
Lockfilepackage-lock.jsonpnpm-lock.yamlyarn.lock

Build files

FilePurposeCommit?
package.jsonName, version, dependencies, scripts, engines, packageManagerYes
package-lock.json / pnpm-lock.yaml / yarn.lockExact resolved versions with integrity hashes (commit exactly one)Yes
.npmrcRegistry URLs, scoped registries, settings like ignore-scriptsYes, with tokens as ${NPM_TOKEN}
.nvmrc / .node-versionNode.js version for nvm, fnm, setup-nodeYes
pnpm-workspace.yamlpnpm workspace packages and settingsYes
.yarnrc.yml, .yarn/releases/Yarn Berry config and pinned Yarn binaryYes
node_modules/Installed packagesNo

Commands by category

1
2
3
4
5
6
7
8
9
npm ci                                           # reproducible install from lockfile
npm run lint                                     # lint (ESLint via package.json script)
semgrep scan --config p/nodejs --error           # SAST
npm audit --audit-level=high --omit=dev          # SCA for production dependencies
npm audit signatures                             # verify registry signatures / provenance
npm test -- --coverage                           # tests + coverage
npx @cyclonedx/cyclonedx-npm --output-file sbom.json   # SBOM
npm pack                                         # package as .tgz
npm publish --provenance --access public         # publish with build provenance

Minimal project

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "name": "@example/api",
  "version": "1.0.0",
  "type": "module",
  "packageManager": "pnpm@10.4.0",
  "engines": { "node": ">=22" },
  "files": ["dist"],
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "lint": "eslint .",
    "test": "vitest run --coverage",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "express": "^5.0.0"
  },
  "devDependencies": {
    "@vitest/coverage-v8": "^3.0.0",
    "eslint": "^9.0.0",
    "typescript": "^5.6.0",
    "vitest": "^3.0.0"
  }
}
1
2
3
4
# .npmrc — private registry for one scope, token from the environment
@example:registry=https://artifactory.example.com/artifactory/api/npm/npm-virtual/
//artifactory.example.com/artifactory/api/npm/npm-virtual/:_authToken=${NPM_TOKEN}
engine-strict=true

Frameworks

FrameworkStart a project
Expressnpm init -y && npm install express
NestJSnpx @nestjs/cli new api
Fastifynpm init fastify
Next.jsnpx create-next-app@latest web

CI with GitHub Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
name: node-ci
on: [push, pull_request]
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm                         # use pnpm/action-setup first for pnpm

      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm audit --audit-level=high --omit=dev
      - run: npm run build
      - run: npx @cyclonedx/cyclonedx-npm --output-file sbom.json

      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: |
            dist/
            sbom.json

  publish:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write                        # required for --provenance
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          registry-url: https://registry.npmjs.org
      - run: npm ci && npm run build
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Best practices

  • Commit exactly one lockfile and install with the frozen/immutable flag in CI.
  • Pin the package manager with the packageManager field and the Node.js version with .nvmrc.
  • Treat install scripts as untrusted. Consider ignore-scripts=true in CI (and allow-list the packages that need them), and use pnpm’s onlyBuiltDependencies / minimumReleaseAge settings.
  • Proxy the registry through Artifactory or Nexus and route scoped packages to the private registry to prevent dependency confusion.
  • Audit production dependencies with --omit=dev as a gate, and keep a scheduled full audit.
  • Publish with provenance and verify it with npm audit signatures.
  • Automate updates with Dependabot or Renovate, grouped so PRs stay reviewable.
This post is licensed under CC BY 4.0 by the author.