Why monorepo Docker images get large, and what a pruned lockfile changes

In an Nx workspace, every project shares a single root package.json and one lockfile. That keeps versions consistent across the repo, but it causes problems in a Dockerfile. If you copy those two files into an image for one API service and run an install, you get the dependencies of every project in the workspace. That includes the React frontend's UI libraries, another service's database drivers, and the test tooling. The service imports a small fraction of these.

This causes three problems:

  • Larger images. Unused packages make every push, pull and cold start slower.
  • Larger attack surface. Every package in the image ends up in vulnerability scan results, even if your code never loads it. Engineers then spend time triaging findings that do not apply to the service.
  • Poor layer caching. The install layer is keyed on the lockfile. When any project changes a dependency, the lockfile changes, so every service's image reinstalls everything, including services whose dependencies did not change.

Nx can fix this with the generatePackageJson option on its build executors. When the option is enabled, the build step uses the project graph to find which external packages the app actually depends on. It then writes a package.json and a pruned lockfile into the build output. The pruned lockfile contains only those packages, at the versions pinned in the root lockfile. Your Dockerfile copies these two files instead of the root ones.

This approach suits backend and platform engineers who ship several Node services from one workspace. It pays off most when the workspace has many projects whose dependencies differ significantly.

How generatePackageJson uses the project graph

When a Node app is built with one of Nx's bundling executors and generatePackageJson is enabled, Nx does not scan your node_modules or copy the root manifest. It consults the project graph, which already records which files import which modules. From that graph it collects the external npm packages the app reaches, directly or through the internal libraries it depends on, and resolves their versions from the root lockfile. It then writes two files into the build output next to the compiled code: a package.json that lists only those packages, and a lockfile pruned to them and their transitive dependencies.

Bundler externals decide what appears in the manifest

The graph shows what the app imports. The bundler configuration decides what the runtime still has to install. A package that the bundler inlines into the output file is already present in the compiled code, so it does not need an entry in the manifest. A package marked as external is left as a runtime require or import, so it must be listed and installed in the image. Native modules and packages that load files dynamically are usually kept external for this reason.

Internal workspace libraries are a separate case. They are compiled into the bundle, so they should never show up as dependencies. If one does, it would point at a package that exists only inside your workspace, and the production install would fail.

End-to-end flow

Source files feed the Nx build, which emits the bundle, the manifest and the pruned lockfile into dist. The Docker runtime stage copies only that directory and runs a frozen install against the pruned lockfile.

+----------------------------------------------------------+
| Source: apps/api + libs/* (internal workspace libraries) |
+----------------------------------------------------------+
                             |
                             v
+----------------------------------------------------------+
| Nx build (bundling executor, generatePackageJson: true)  |
|  project graph -> external packages app reaches          |
|  root lockfile -> resolved versions                      |
|  internal libs      -> bundled, never a dependency       |
|  bundled npm pkgs   -> inlined, left out of manifest     |
|  external npm pkgs  -> runtime import, must be listed    |
+----------------------------------------------------------+
                             |
                             v
+----------------------------------------------------------+
| dist/apps/api                                            |
|  main.js        compiled bundle                          |
|  package.json   external packages only                   |
|  lockfile       pruned to externals + transitive deps    |
+----------------------------------------------------------+
                             |  COPY dist/apps/api only
                             v
+----------------------------------------------------------+
| Docker runtime stage                                     |
|  frozen install against pruned lockfile                  |
|  node_modules = externals only, then run main.js         |
+----------------------------------------------------------+

With generatePackageJson enabled, the Nx build uses the project graph and the bundler's externals to write a bundle, a minimal package.json and a pruned lockfile into dist, which is the only thing the Docker runtime stage copies and installs from.

Enabling generatePackageJson and verifying the generated manifest

Add "generatePackageJson": true to the options of the app's build target in project.json. Set it on the base options, not only the production configuration, so development builds produce the same manifest and you catch gaps early. Then run the production build (nx build api --configuration=production) and open the output folder under dist. You should see the bundle, a package.json listing only the packages this app's graph reaches, and a lockfile for your package manager that has been pruned to match. If the dependency list looks close to the root manifest, something in the graph is pulling in too much. Check that before going further.

Diff declared against loaded

Static analysis only sees literal imports. To find what it missed, start the built app locally and exercise its main paths. At shutdown, dump the keys of require.cache and reduce them to package names under node_modules. Diff that list against the generated manifest. These are the usual gaps:

  • Requires built from strings at runtime
  • Plugin systems that load modules by name
  • Peer and optional dependencies
  • Native addons
  • ORM clients or CLIs that run at startup, such as migration runners

Fixing the manifest

If a workspace library shows up as a dependency, it is being treated as external. Make sure it is imported through its path alias so the bundler inlines it.

If a package is bundled when it should be installed, which is common with native modules, adjust the executor's externals setting so the bundle and the manifest agree.

For packages that analysis cannot detect, declare them in a project-level package.json, where Nx can merge them into the manifest. Then rebuild and diff again.

// tools/dump-loaded.js: preload into the built app to compare loaded vs declared packages.
// Usage: MANIFEST=dist/apps/api/package.json node -r ./tools/dump-loaded.js dist/apps/api/main.js
// Assumes a CommonJS bundle. ESM imports do not populate require.cache.
const fs = require('fs');
const path = require('path');

const manifestPath = process.env.MANIFEST || 'dist/apps/api/package.json';

function packageName(file) {
  const parts = file.split(path.sep);
  const i = parts.lastIndexOf('node_modules');
  if (i === -1) return null; // inlined workspace code or app bundle, not an installed package
  const first = parts[i + 1];
  return first.startsWith('@') ? `${first}/${parts[i + 2]}` : first;
}

// Make Ctrl+C run the exit hook. Remove this if the app installs its own SIGINT handler.
process.on('SIGINT', () => process.exit(0));

process.on('exit', () => {
  const loaded = new Set(Object.keys(require.cache).map(packageName).filter(Boolean));
  const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
  const declared = new Set([
    ...Object.keys(m.dependencies || {}),
    ...Object.keys(m.optionalDependencies || {}),
    ...Object.keys(m.peerDependencies || {}),
  ]);

  // Loaded but not declared. This list includes transitive deps, which the pruned
  // lockfile already covers. Anything not in the lockfile will break in the image.
  const missing = [...loaded].filter((n) => !declared.has(n)).sort();
  // Declared but never loaded on the paths you exercised. Review these, but do not
  // remove them blindly: they may load lazily or only on paths you did not run.
  const unused = [...declared].filter((n) => !loaded.has(n)).sort();

  console.error('Loaded but not declared:', missing);
  console.error('Declared but not loaded:', unused);
});

A Node preload script that records which node_modules packages the built app actually loads and diffs them against the Nx-generated package.json.

Writing a multi-stage Dockerfile with a frozen production install

Then point the Dockerfile at the build output directory, which now holds everything the image needs: the compiled JavaScript, the generated package.json, and the pruned lockfile.

Stage layout

Use two stages. If you build inside Docker, the first stage runs the Nx build. If CI already produced dist/apps/api, the first stage can be skipped. The runtime stage should start from the same base image family you plan to ship. It does the following, in this order:

  1. Copy only package.json and the lockfile from the build output.
  2. Run a frozen, production-only install:
    • npm: npm ci --omit=dev
    • pnpm: pnpm install --frozen-lockfile --prod
    • Yarn Classic: yarn install --frozen-lockfile --production
  3. Copy the compiled JavaScript.
  4. Switch to a non-root user. Official Node images include a node user.
  5. Set the entrypoint to node main.js.

The frozen flags make the install fail if the manifest and lockfile disagree, so it never silently re-resolves versions.

This order matters for caching. Docker invalidates a layer only when its inputs change. The dependency layer depends on just the manifest and lockfile, so a code-only change rebuilds only the final copy layer. The install is reused from cache.

.dockerignore

At minimum, exclude node_modules, .git, .nx, coverage, and local .env files. This keeps the build context small and prevents host-installed modules from leaking into the image.

Native modules

Packages with compiled bindings, such as bcrypt or sharp, are built against a specific libc. A binary built on a glibc host will not load on Alpine, which uses musl. Run the install inside an image that matches your runtime base, never on the host, and copy the result forward.

# syntax=docker/dockerfile:1
# Both stages use the same Debian (glibc) base, so native addons
# built here load at runtime. Don't mix with an Alpine (musl) stage.

# ---- Build stage: run the Nx build (skip if CI already produced dist/) ----
FROM node:20-bookworm-slim AS build
WORKDIR /workspace
# Install full workspace deps first so this layer caches across code edits
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Emits compiled JS plus the generated package.json and pruned lockfile
RUN npx nx build api

# ---- Runtime stage ----
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app

# 1. Manifest and pruned lockfile only. This layer changes only when deps change.
COPY --from=build /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./

# 2. Frozen, production-only install, run inside the runtime base image
#    pnpm: pnpm install --frozen-lockfile --prod
#    Yarn Classic: yarn install --frozen-lockfile --production
RUN npm ci --omit=dev && npm cache clean --force

# 3. Compiled JS last, so code-only changes invalidate only this layer
COPY --from=build /workspace/dist/apps/api/ ./

# 4. Drop root. Official Node images ship a 'node' user.
USER node

# 5. Start the compiled entrypoint directly with node
ENTRYPOINT ["node", "main.js"]

# .dockerignore (separate file at the repo root):
#   node_modules
#   .git
#   .nx
#   coverage
#   .env*

A two-stage Dockerfile installs production dependencies from the pruned lockfile before it copies the compiled code, so code-only changes reuse the cached install layer, and both stages share a glibc base so native modules load at runtime. The minimal .dockerignore is shown at the end.

Alternatives: root lockfile, building inside Docker, or building in CI

The pruned-lockfile approach is one of three common ways to package an Nx app. The other two make different trade-offs.

Root lockfile. Copy the workspace package.json and lockfile, then run a production install. It is simple, but every dependency used by any project in the workspace ships in every image. A dependency change anywhere also invalidates the install layer for all services.

Building inside Docker. Send the whole workspace as build context and run nx build in a builder stage. It needs no CI artifacts, but the context is large and the local Nx cache is usually not available inside the build. Without careful .dockerignore rules, small unrelated edits can trigger a full rebuild.

Building in CI. Run nx affected with remote or local caching, then copy the prebuilt dist into a small Dockerfile. Builds are fast and the context is small, but the image depends on CI having produced the right artifact.

Approach Image size Cache behaviour Build context Needs CI artifacts Complexity
Root lockfile Largest Busts on any dep change Small No Low
Build in Docker Depends on install step Weak, no Nx cache Whole repo No Medium
CI build + pruned lockfile Smallest Nx cache plus layer cache dist only Yes Medium

To compare approaches, build each image and read the SIZE column from docker image ls. Then run docker run --rm <image> ls node_modules | wc -l. Scoped packages count as one entry per scope, so treat the result as a relative measure.

Illustrative estimate: assume a workspace with 900 top-level packages and an API that imports 60 of them. The pruned image installs roughly 60 packages plus their transitive dependencies, while the root-lockfile image installs all 900.

#!/usr/bin/env bash
# Build the same app three ways and compare image size and installed package count.
# Assumes three Dockerfiles exist and each final image keeps its deps in /app/node_modules.
set -euo pipefail

declare -A dockerfiles=(
  [api-root-lock]=docker/Dockerfile.root-lockfile
  [api-in-docker]=docker/Dockerfile.build-in-docker
  [api-pruned]=docker/Dockerfile.ci-pruned
)

for tag in "${!dockerfiles[@]}"; do
  # Same build context for all three, so only the Dockerfile differs.
  docker build -f "${dockerfiles[$tag]}" -t "$tag:measure" .
done

# Human-readable size, as shown in the SIZE column.
docker image ls --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' | grep ':measure'

for tag in "${!dockerfiles[@]}"; do
  # Exact size in bytes, easier to diff than the rounded SIZE column.
  bytes=$(docker image inspect --format '{{.Size}}' "$tag:measure")

  # Top-level entries: each @scope counts once, so this is only a relative measure.
  top=$(docker run --rm --entrypoint sh "$tag:measure" \
    -c 'ls /app/node_modules | wc -l')

  # Expand scopes so @nestjs/core and @nestjs/common count separately.
  # Skips dot-entries such as .bin; still excludes nested node_modules.
  expanded=$(docker run --rm --entrypoint sh "$tag:measure" \
    -c 'cd /app/node_modules && ls -d [!@]* @*/* 2>/dev/null | wc -l')

  printf '%-16s bytes=%-12s top-level=%-6s with-scopes=%s\n' \
    "$tag" "$bytes" "$top" "$expanded"
done

A script that builds the root-lockfile, build-in-Docker and CI-plus-pruned-lockfile images from the same context, then reports each image's size and node_modules package count so you can produce your own comparison instead of relying on estimates.

When to avoid it and what can go wrong

The generated manifest is only as accurate as the project graph. Nx infers dependencies from static imports, so a require() built from a variable, a plugin loaded by name from a config file, or a driver that an ORM resolves at runtime can be missing from the output. The build passes and the image starts, then a code path fails with Cannot find module. Run the container in CI and exercise those paths, not just the health check.

Bundler externals cause a related mismatch. If the bundler inlines a package, the manifest still lists it and you install it for nothing. If you mark a package external but the graph never sees it, the bundle expects a module that was never installed. Keep the externals list and the manifest consistent, and review both when you change bundling settings.

Lockfile pruning behaves differently across npm, Yarn and pnpm, and across lockfile formats. Workspace protocols, patches and overrides do not always carry through. Make the frozen install a required CI step so a pruned lockfile that cannot be installed fails the pipeline instead of the deploy.

Local builds can also go wrong. If docker build copies dist without running an Nx build first, the image may contain stale or missing output. Developers also tend to edit the generated manifest by hand, then wonder why the change disappears on the next build.

A pruned install does not replace a slim base image, a non-root user or vulnerability scanning. It only reduces the number of packages those controls have to cover.

For small workspaces with few shared dependencies, skip this approach. The difference in image size is small, and the extra configuration and CI checks cost more than they save.

Wiring it into deployment and the impact on cost and engineering time

Adding a per-app image target

Give each deployable app a docker-build target in its project.json with dependsOn set to build. Nx then runs the bundler and writes the pruned package.json and lockfile before Docker reads the build context, so the image can never be built from a stale manifest. Tag images with the commit SHA instead of latest. Each deployed image then traces back to exactly one commit, and a rollback is a redeploy of an earlier tag.

In CI, run nx affected --target=docker-build against your base branch. An app is rebuilt only when something in its project graph changed, so a commit to one service's library does not rebuild every other service.

Before pushing, start the container and confirm it stays up past startup or answers a health check. If the graph missed a dependency, the "Cannot find module" error appears in CI and not in production.

Cost and engineering time

The savings come from CI minutes, registry storage and image pull time on every node that schedules the app.

Illustrative estimate (assumed numbers, not a benchmark): monthly pushed data = apps rebuilt per merge × merges × image size. Assume 10 apps, 200 merges, images shrinking from 900 MB to 300 MB, and 3 apps rebuilt per merge instead of 10:

  • Before: 10 × 200 × 900 MB = 1,800 GB
  • After: 3 × 200 × 300 MB = 180 GB

These are upper bounds, because shared layers are deduplicated.

When an image breaks, you debug one short manifest instead of the root one. The cost is configuration spread across project.json, the bundler config, the Dockerfile and CI, and all four have to stay consistent. That cost pays off once you have several deployable apps whose dependencies diverge.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx docker build prune lockfile, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.