What pruned lockfiles are and why the naive monorepo Docker image hurts
A pruned lockfile is a copy of your monorepo's lockfile trimmed down to the packages one application actually needs, along with their transitive dependencies. The versions stay pinned exactly as they are in the root lockfile. Only the entries the app never touches are removed.
The naive way to containerize a service from a monorepo is to copy the root package.json and lockfile into the image, run a full install, then copy in the built app. It works, but it has two costs.
- Every image carries every dependency. Your payments API ships with the image-processing library the media service uses, the ORM the reporting job uses, and any frontend packages declared at the root. Image size, pull time and vulnerability-scan noise grow with the whole repo, not with the service.
- The dependency layer cache is shared by everyone. Docker caches the install layer based on the files copied before it. Because every service copies the same root lockfile, any dependency change by any team invalidates that layer for all services. A version bump in one app forces a full reinstall in every image build.
The alternative is to have Nx generate a package.json and a pruned lockfile for each app at build time. Each image then installs only what that app's project graph references. Its install layer changes only when its own dependencies change.
This suits backend and platform engineers who run several Node services from one Nx repo. The payoff grows with the number of services and with how different their dependency sets are.
How Nx infers a service's dependencies from the project graph
When you build a Node app, Nx already has a project graph of the workspace. It records which libraries the app imports, and it parses the root lockfile into nodes for each external package. With generatePackageJson enabled, the build walks from the app through every library it depends on and collects the external packages those projects import statically. That covers import statements and require() calls with literal strings. It looks up each package's version range in the root package.json and writes a trimmed package.json that lists only those packages. It then filters the root lockfile down to that subtree: the direct dependencies, plus every transitive package they pull in, at the exact versions already resolved.
root lockfile ──┐
├──> nx build <service> ──> dist/apps/<service>/
project graph ──┘ (app + its libs, ├── package.json (only used deps)
static imports) ├── <lockfile> (pruned subtree)
└── main.js (compiled output)
The pruned lockfile matters more than the trimmed package.json. A bare package.json holds ranges such as ^4.18.0. If you install from it without a lockfile, the package manager resolves again against the registry as it stands on the day of the build. You can get a newer minor version of a direct dependency, or a different version of something three levels down. The filtered lockfile pins every transitive package to the version your test suite ran against in the repo. A frozen install inside the image then reproduces that dependency tree, minus the packages this service never touches.
+--------------------+ +-----------------------------+
| root lockfile | | project graph |
| (all packages, | | app -> libs (imports) |
| resolved versions)| | external npm package nodes |
+---------+----------+ +--------------+--------------+
| |
+--------------------+-----------+
|
v
+---------------------------------------------+
| nx build <service> |
| (generatePackageJson: true) |
| 1. walk app + its libraries |
| 2. collect static imports / require() |
| 3. look up ranges in root package.json |
| 4. filter root lockfile to that subtree |
+----------------------+----------------------+
|
v
+---------------------------------------------+
| dist/apps/<service>/ |
| +-- package.json (only used deps) |
| +-- <lockfile> (pruned subtree, |
| | exact versions) |
| +-- main.js (compiled output) |
+---------------------------------------------+
With generatePackageJson enabled, nx build combines the project graph's static imports with the root lockfile. It writes a trimmed package.json and a lockfile pruned to that dependency subtree, still pinned to the versions the repo resolved, into dist/apps/ alongside the compiled code.
Enabling generatePackageJson and inspecting the output
Turning this on takes one option in the service's build target. In apps/orders-api/project.json, find the build target that uses @nx/webpack:webpack or @nx/esbuild:esbuild, and add "generatePackageJson": true to its options. Keep outputPath pointing at something like dist/apps/orders-api, because that folder becomes the Docker build input later. Executor names and the exact option spelling have changed across Nx releases, so check the executor documentation for the Nx version in your root package.json before copying a target from elsewhere.
Run nx build orders-api, then list the output folder with ls dist/apps/orders-api. Next to the bundled main.js you should find a package.json and a lockfile in your package manager's format: package-lock.json, yarn.lock or pnpm-lock.yaml. If the lockfile is missing, the image cannot do a frozen install. Fix that before you write the Dockerfile.
Open the generated package.json and check three things:
- Expected dependencies are present. Every runtime package the service imports should appear under
dependencies, including database drivers loaded through a shared library. - No dev-only packages. Test runners, linters, type packages and build plugins should be absent. If they appear, something in the service's import chain probably references them at runtime.
- Versions match the root lockfile. Compare a few entries, including a transitive package, against the root lockfile. If the versions drift, the image runs code your CI never tested.
#!/usr/bin/env bash
# Assumes apps/orders-api/project.json has a build target (@nx/webpack:webpack or
# @nx/esbuild:esbuild) with "generatePackageJson": true and
# "outputPath": "dist/apps/orders-api". Assumes npm (package-lock.json v2/v3).
set -euo pipefail
OUT=dist/apps/orders-api
npx nx build orders-api
# Expect main.js, package.json and a lockfile side by side.
ls -1 "$OUT"
test -f "$OUT/package-lock.json" || { echo "No lockfile: fix before writing the Dockerfile"; exit 1; }
# 1. Runtime dependencies the bundle needs, including drivers pulled in via shared libs.
node -e 'const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
console.log("dependencies:", p.dependencies);
if (p.devDependencies) console.log("devDependencies present:", Object.keys(p.devDependencies));' "$OUT/package.json"
# 2. Dev-only tooling should not appear anywhere in the generated manifest.
if grep -E '"(jest|eslint|typescript|webpack|esbuild|@types/[^"]+)"' "$OUT/package.json"; then
echo "WARNING: dev-only package in generated package.json"
fi
# 3. Versions must match the root lockfile: check a direct dep (pg) and a
# transitive one (pg-protocol). Replace with packages your service uses.
for pkg in pg pg-protocol; do
node -e 'const fs = require("fs");
const v = f => JSON.parse(fs.readFileSync(f, "utf8")).packages?.["node_modules/" + process.argv[1]]?.version;
const root = v("package-lock.json"), pruned = v(process.argv[2]);
console.log(process.argv[1], "root:", root, "pruned:", pruned, root === pruned ? "OK" : "MISMATCH");' \
"$pkg" "$OUT/package-lock.json"
done
Builds orders-api with generatePackageJson enabled, confirms the pruned lockfile exists in dist, and checks the generated manifest for runtime dependencies, stray dev-only packages and version parity with the root package-lock.json.
A multi-stage Dockerfile that caches dependencies separately from code
The image build has three steps, and their order is what makes caching work. First, produce the build output, either in CI before docker build or in a builder stage that runs nx build for the one service. Second, in the runtime stage, copy only the generated package.json and pruned lockfile, then run a frozen production install: npm ci with dev dependencies omitted, or the frozen-lockfile mode of pnpm or Yarn. Third, copy the compiled output.
Docker invalidates a layer when its inputs change. Most commits change only application code, so the manifest and lockfile stay byte-identical and the install layer comes from cache. Only the final copy is rebuilt. Without pruning, any dependency change anywhere in the monorepo changes the root lockfile and forces a full reinstall for every service. With pruning, only the services whose dependencies actually changed reinstall.
Native modules need care. Packages with compiled addons fetch or build binaries for the OS and libc of the machine that runs the install. A binary built against glibc on a Debian-based CI runner fails on an Alpine image, which uses musl. Run the install inside the runtime base image, or a builder stage that uses the same base image, and never copy node_modules in from the host. Pruning reduces how many packages you install, but it does nothing to fix a platform mismatch.
Where you build is also a provenance trade-off:
- Building in CI and copying artifacts in reuses the Nx cache and keeps image builds fast. The image then depends on whatever the runner produced, though.
- Building in a builder stage makes the Dockerfile self-contained and reproducible from source. It loses Nx's computation cache unless you wire in a remote cache.
# Every stage uses the same base image, so native addons are built for musl (Alpine)
FROM node:20-alpine AS builder
# Toolchain for any native addons that must compile from source
RUN apk add --no-cache python3 make g++
WORKDIR /repo
# Full workspace install, with dev deps, needed to run the Nx build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Assumes the project's build target sets generatePackageJson: true, so dist/
# gets a package.json and a pruned package-lock.json for this service only
RUN npx nx build api --configuration=production
FROM node:20-alpine AS deps
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy only the pruned manifest and lockfile. When their bytes are unchanged,
# this layer and the install below are served from cache, even if code changed.
COPY --from=builder /repo/dist/apps/api/package.json /repo/dist/apps/api/package-lock.json ./
# Frozen, production-only install, run inside the target OS/libc
RUN npm ci --omit=dev
# Runtime stage: no compilers, only production deps and compiled output
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app
# node_modules comes from a stage on the same base image, never from the host
COPY --from=deps /app/node_modules ./node_modules
# Code changes invalidate only this final layer
COPY --from=builder /repo/dist/apps/api ./
USER node
CMD ["node", "main.js"]
A multi-stage Dockerfile that builds one Nx service in a builder stage, runs a frozen production install from the pruned lockfile so the dependency layer caches separately from code, and keeps every stage on the same Alpine base so native modules match the runtime's musl libc.
When to avoid it: dependencies the graph cannot see
The pruned package.json only contains packages that the project graph can trace through static imports. Any code that resolves a module at runtime without a literal import is invisible to it. The build still passes, and the container fails on its first request or at startup.
| Blind spot | Symptom | Mitigation |
|---|---|---|
require(variable), string module names |
Cannot find module at runtime |
List the package in the app-level package.json, if your Nx version merges it |
| Config-driven plugins (loggers, transports) | Plugin silently missing or init error | Same, or add a static import in a registry file |
| ORM drivers loaded by name | "Driver not installed" on connect | Import the driver explicitly or declare it |
| Migration tools and CLIs | Binary absent from image | Build a separate migration job image |
| ts-node or tsx loaders | Startup fails in production | Run compiled JS, not a loader |
| Unmet peer dependencies | Install warnings or crashes | Declare peers explicitly |
| Workspace project used only at runtime | Missing local package | Add implicitDependencies in project.json |
workspace: protocol, patched packages |
Install fails or patch is lost | Copy patch files into the image and verify |
Package managers handle pruned lockfiles differently. npm, Yarn classic, Yarn Berry and pnpm each store resolutions, workspace links and patches in their own format. A lockfile that installs cleanly under one manager may be rejected by a frozen install under another, or may drop a patch without any error. Test the pruning step with the exact package manager and Nx version your CI uses, and pin both so that an upgrade cannot change the output without you noticing.
// apps/orders-api/src/db/drivers.ts
// The ORM selects its driver by name from config (e.g. DB_CLIENT=pg).
// The project graph cannot see that runtime lookup, so the pruned
// package.json would leave the driver out. Importing every supported
// driver here gives the graph a static import to trace.
import * as pg from 'pg';
import * as mysql2 from 'mysql2';
// Only drivers listed here are installed in the image. Adding a new
// database means adding a new import, which keeps the graph accurate.
const drivers = { pg, mysql2 } as const;
export type DriverName = keyof typeof drivers;
export function loadDriver(name: string) {
if (!(name in drivers)) {
// Fail at startup with a clear message instead of on the first query.
throw new Error(
`Unsupported DB driver "${name}". Supported: ${Object.keys(drivers).join(', ')}`
);
}
return drivers[name as DriverName];
}
// apps/orders-api/src/main.ts
// import { loadDriver } from './db/drivers';
// const driver = loadDriver(process.env.DB_CLIENT ?? 'pg');
A driver registry file that turns a config-driven, name-based driver lookup into static imports, so the pruned package.json keeps the database drivers and an unsupported driver name fails at startup.
Verifying images and wiring per-service builds into CI
Treat the generated package.json as a reviewable artifact. Commit a snapshot of the expected dependency list per service, and add a CI step that builds the service and diffs the generated manifest against that snapshot. An unexpected addition usually means a new import pulled in a heavy library. An unexpected removal usually means the graph lost sight of something. Either way, a person decides before the image ships. Without this check, the deployed manifest drifts from what engineers think is installed, and debugging a production MODULE_NOT_FOUND turns into archaeology.
A clean diff does not prove the image works. Start the container and hit the paths that load code lazily: dynamic imports, plugin loaders, database drivers chosen by configuration, and anything else that runs after startup. A smoke test that only checks the health endpoint will miss most of these.
Measure the size rather than assuming it shrank. Use docker image ls to compare totals against the unpruned build, and use docker history to see which layer holds the bytes. If the dependency layer is still large, look there first.
Wiring it into CI
Run nx affected against the build target so only projects whose graph changed since the base commit get built. Then loop over the affected application projects and build and push one image per service. A dependency bump in one app rebuilds that app's image and leaves the others alone. Pipelines finish sooner, and a bad upgrade can only break the services that actually consume it.
Alternatives, cost impact and an illustrative estimate
| Approach | Image size | Cache behavior | Correctness risk | Maintenance effort |
|---|---|---|---|---|
Fat image, root node_modules |
Largest | Any lockfile change invalidates every service | Low; everything is present | Low |
| Shared base image layer | Large, but layer stored once | Base rebuild cascades to all services | Low | Medium; base needs its own pipeline |
Hand-maintained per-app package.json |
Small | Good per service | High; versions drift from the root lockfile | High |
| Bundle all dependencies into output | Smallest | Rebuilds on any code or dependency change | Medium; native modules and dynamic requires break | Medium |
| Nx pruned lockfile | Small | Good per service | Medium; graph can miss runtime-only imports | Low after setup |
Pruning adds moving parts. The generated package.json and lockfile are now part of the deployment contract. Review them when Nx is upgraded, because a change in inference changes what ships. You also give up layer sharing. Identical node_modules layers are stored once in a registry and pulled once per host. Distinct pruned layers are not, so savings can shrink or reverse.
Illustrative estimate (assumed numbers, not a benchmark)
Assume N = 10 services, a shared dependency set S = 800 MB, and an average per-service subset s = 150 MB. Ignore application code.
- Registry storage, shared layer: S = 800 MB
- Registry storage, pruned: N × s = 1,500 MB
- Pull on a host running k = 3 services, shared layer: S = 800 MB
- Pull on that host, pruned: k × s = 450 MB
Under these assumptions, pruning roughly doubles registry storage. It also cuts what each host pulls by nearly half. Put in your own N, S, s and k before you decide.
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 prune lockfile docker, 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
.envby 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.