Skip to content
Back to Knowledge Base

Pruning Projects for Deployment

When deploying Node.js applications to containers, you typically need only production dependencies, not your entire workspace node_modules. Pruning generates a standalone package.json, a pruned lockfile, and copies any workspace libraries your app depends on. The result is everything you need to run npm ci inside a Docker image with only the packages your application uses.

To bundle your app into a single file instead (no node_modules needed), see Bundling projects for deployment.

ApproachBest forTrade-off
BundlingServerless functions, simple APIsSingle file output, no node_modules needed
PruningDocker deployments, native dependenciesKeeps node_modules but only production deps

Use pruning when:

  • Your app has native dependencies (e.g. bcrypt, sharp) that can't be bundled
  • You want Docker layer caching, where dependency layers rebuild only when package.json changes
  • You consume workspace libraries as packages rather than bundling them

Pruning uses four Nx targets that run in sequence:

  1. build compiles your application (esbuild, webpack, tsc, etc.).
  2. prune-lockfile (@nx/js:prune-lockfile) reads your project package.json, generates a minimal package.json, and creates a pruned lockfile containing only production dependencies.
  3. copy-workspace-modules (@nx/js:copy-workspace-modules) copies workspace libraries referenced via workspace:* into a workspace_modules/ directory and rewrites their dependency references to file: paths.
  4. prune (nx:noop) depends on both prune-lockfile and copy-workspace-modules, giving you a single command to run.

After running nx prune my-app, the build output directory contains:

apps/my-app/dist/
├── main.js # Compiled application
├── package.json # Pruned production dependencies
├── package-lock.json # Pruned lockfile (or yarn.lock / pnpm-lock.yaml)
├── pnpm-workspace.yaml # pnpm only, carries the install settings
├── patches/ # pnpm only, if the workspace uses `pnpm patch`
├── local_path_modules/ # pnpm only, non-workspace file:/link: dependencies
└── workspace_modules/ # Only present if you have workspace deps
└── @my-org/
└── my-lib/
├── package.json
└── ...

Add the following targets to your project's package.json or project.json:

apps/my-app/package.json
{
"name": "@my-org/my-app",
"nx": {
"targets": {
"prune-lockfile": {
"dependsOn": ["build"],
"cache": true,
"executor": "@nx/js:prune-lockfile",
"outputs": [
"{workspaceRoot}/apps/my-app/dist/package.json",
"{workspaceRoot}/apps/my-app/dist/package-lock.json"
],
"options": {
"buildTarget": "build"
}
},
"copy-workspace-modules": {
"dependsOn": ["build"],
"cache": true,
"outputs": ["{workspaceRoot}/apps/my-app/dist/workspace_modules"],
"executor": "@nx/js:copy-workspace-modules",
"options": {
"buildTarget": "build"
}
},
"prune": {
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
"executor": "nx:noop"
}
}
}
}

Replace package-lock.json in the outputs array with yarn.lock or pnpm-lock.yaml to match your package manager.

The emitted pnpm-workspace.yaml carries build-script approvals (allowBuilds), supportedArchitectures, and any patchedDependencies. On pnpm 10 and below it carries none of them, because the emitted package.json declares them instead, but the file still ships so that every run overwrites the last one. Copied .patch files keep their original subpath under patches/. Resolution-time config such as pnpm.overrides is dropped from the emitted manifest, because the pruned lockfile already bakes it into its resolutions. A re-resolve inside the deploy directory, such as an install without the lockfile or adding a dependency there, loses those pins.

For pnpm, the complete prune-lockfile target declares the extra artifacts in outputs so a cached run restores them, and the workspace root settings files in inputs so revoking a build-script approval does not replay a cached output that still grants it. The approvals and supportedArchitectures live only in those two files, and nothing records them in the lockfile. The runtime input hashes the pnpm major, which decides whether the settings land in the emitted pnpm-workspace.yaml or the emitted package.json when the root manifest has no packageManager field:

apps/my-app/project.json
{
"targets": {
"prune-lockfile": {
"dependsOn": ["build"],
"cache": true,
"executor": "@nx/js:prune-lockfile",
"inputs": [
"default",
"^default",
"{workspaceRoot}/pnpm-workspace.yaml",
"{workspaceRoot}/package.json",
{
"runtime": "node -e \"try{console.log('pnpm major '+require('child_process').execSync('pnpm --version',{stdio:['ignore','pipe','ignore']}).toString().trim().split('.')[0])}catch{console.log('pnpm major unavailable')}\""
}
],
"outputs": [
"{workspaceRoot}/apps/my-app/dist/package.json",
"{workspaceRoot}/apps/my-app/dist/pnpm-lock.yaml",
"{workspaceRoot}/apps/my-app/dist/pnpm-workspace.yaml",
"{workspaceRoot}/apps/my-app/dist/patches",
"{workspaceRoot}/apps/my-app/dist/local_path_modules"
],
"options": {
"buildTarget": "build"
}
}
}
}

Then run:

Terminal window
nx prune my-app

Both prune-lockfile and copy-workspace-modules set cache: true, so subsequent runs are instant when nothing changes.

The generated Dockerfile copies the build output and runs npm install:

# apps/my-app/Dockerfile
FROM docker.io/node:lts-alpine
ENV HOST=0.0.0.0
ENV PORT=3000
WORKDIR /app
COPY dist .
# You can remove this install step if you build with `--bundle` option.
# The bundled output will include external dependencies.
RUN npm --omit=dev -f install
CMD ["node", "main.js"]

The COPY dist . line works because the Dockerfile lives inside the project directory (apps/my-app/), and the build output goes to apps/my-app/dist/. The pruned package.json, lockfile, and workspace_modules/ are all inside dist/.

Build and run:

Terminal window
# Build the app and prune dependencies
nx prune my-app
# Build the Docker image
npx nx docker:build my-app
# Run the container
nx docker:run my-app -p 3000:3000

Legacy build targets that emit deploy output

Section titled “Legacy build targets that emit deploy output”

In a pnpm workspace, an explicit build target that enables generatePackageJson (webpack, rspack, vite, and esbuild) or generateLockfile (next, remix, tsc, and swc) emits the pruned deploy output next to the deployable package.json. The Nx 23.2 migration adds the pnpm settings inputs to the targets that enable one of those options at the time it runs. The migration doesn't revisit targets that enable either option later. Check the effective inputs of such a target and add any missing entries yourself.

Add them to the layer that supplies the target's effective inputs. A target-level inputs array replaces the targetDefaults array instead of merging with it, so append to the target's own array when it declares one, and otherwise to the targetDefaults entry it inherits from. A target that inherits nothing needs the Nx defaults spelled out first:

apps/my-app/project.json
{
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"options": {
"generatePackageJson": true
},
"inputs": [
"default",
"^default",
"{workspaceRoot}/pnpm-workspace.yaml",
{
"json": "{workspaceRoot}/package.json",
"fields": [
"packageManager",
"pnpm.onlyBuiltDependencies",
"pnpm.neverBuiltDependencies",
"pnpm.allowBuilds",
"pnpm.supportedArchitectures",
"pnpm.patchedDependencies"
]
},
{
"runtime": "node -e \"try{console.log('pnpm major '+require('child_process').execSync('pnpm --version',{stdio:['ignore','pipe','ignore']}).toString().trim().split('.')[0])}catch{console.log('pnpm major unavailable')}\""
}
]
}
}
}

The json input hashes only the fields the deploy output is built from, so dependency bumps in the root package.json don't invalidate every build.

This doesn't apply to the prune-lockfile target above, or to build targets inferred by the @nx/webpack and @nx/rspack plugins, which declare these inputs themselves unless a project-level or targetDefaults entry replaces their inputs array. TS Solution Setup rejects generatePackageJson, so use the prune workflow instead of re-enabling it.

If you're upgrading to Nx 20+ with TS Solution Setup (the default for new workspaces), the generatePackageJson option is no longer supported. You'll see this error:

Follow these steps to migrate to the prune workflow:

Step 1: Move dependencies to your project package.json

Section titled “Step 1: Move dependencies to your project package.json”

With TS Solution Setup, each project has its own package.json. List all runtime dependencies there:

apps/my-app/package.json
{
"name": "@my-org/my-app",
"dependencies": {
"express": "^4.18.0",
"@my-org/shared-utils": "workspace:*"
}
}

Use the workspace:* protocol for workspace libraries.

Step 2: Remove generatePackageJson from your build configuration

Section titled “Step 2: Remove generatePackageJson from your build configuration”

Remove generatePackageJson from your esbuild target options:

apps/my-app/package.json
{
"nx": {
"targets": {
"build": {
"executor": "@nx/esbuild:esbuild",
"options": {
"platform": "node",
"outputPath": "dist/apps/my-app",
"format": ["cjs"],
"main": "apps/my-app/src/main.ts",
"tsConfig": "apps/my-app/tsconfig.app.json"
}
}
}
}
}

Add the prune-lockfile, copy-workspace-modules, and prune targets to your project package.json as shown in the set up prune targets section.

Replace references to the old generated package.json with the pruned output. See the use pruned output in Docker section for a recommended Dockerfile structure.

Last updated: