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.
When to prune instead of bundle
Section titled “When to prune instead of bundle”| Approach | Best for | Trade-off |
|---|---|---|
| Bundling | Serverless functions, simple APIs | Single file output, no node_modules needed |
| Pruning | Docker deployments, native dependencies | Keeps 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.jsonchanges - You consume workspace libraries as packages rather than bundling them
How pruning works
Section titled “How pruning works”Pruning uses four Nx targets that run in sequence:
buildcompiles your application (esbuild, webpack, tsc, etc.).prune-lockfile(@nx/js:prune-lockfile) reads your projectpackage.json, generates a minimalpackage.json, and creates a pruned lockfile containing only production dependencies.copy-workspace-modules(@nx/js:copy-workspace-modules) copies workspace libraries referenced viaworkspace:*into aworkspace_modules/directory and rewrites their dependency references tofile:paths.prune(nx:noop) depends on bothprune-lockfileandcopy-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 └── ...Set up prune targets
Section titled “Set up prune targets”Add the following targets to your project's package.json or project.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" } } }}{ "name": "@my-org/my-app", "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:
{ "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:
nx prune my-appBoth prune-lockfile and copy-workspace-modules set cache: true, so subsequent runs are instant when nothing changes.
Use pruned output in Docker
Section titled “Use pruned output in Docker”The generated Dockerfile copies the build output and runs npm install:
# apps/my-app/DockerfileFROM docker.io/node:lts-alpine
ENV HOST=0.0.0.0ENV 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:
# Build the app and prune dependenciesnx prune my-app
# Build the Docker imagenpx nx docker:build my-app
# Run the containernx docker:run my-app -p 3000:3000Legacy 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:
{ "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.
Migrate from generatePackageJson
Section titled “Migrate from generatePackageJson”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:
{ "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:
{ "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" } } } }}Remove generatePackageJson from your webpack config:
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');const { join } = require('path');
module.exports = { output: { path: join(__dirname, '../../dist/apps/my-app'), }, plugins: [ new NxAppWebpackPlugin({ target: 'node', compiler: 'tsc', main: './src/main.ts', tsConfig: './tsconfig.app.json', }), ],};Remove generatePackageJson from your rollup or vite build options. With TS Solution Setup, the project package.json is used directly.
Step 3: Add prune targets
Section titled “Step 3: Add prune targets”Add the prune-lockfile, copy-workspace-modules, and prune targets to your project package.json as shown in the set up prune targets section.
Step 4: Update your Dockerfile
Section titled “Step 4: Update your Dockerfile”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.