Why copying the root package.json into every image breaks caching

The usual first Dockerfile in an Nx workspace copies the root package.json and lockfile into the image, runs npm ci, then copies the built app. That looks cache-friendly because the install step sits in its own layer. The problem is what the layer is keyed on. Docker reuses a COPY layer only if the copied files are byte-identical, and every later layer depends on it. The root manifest describes the entire workspace, so it changes whenever any project changes its dependencies.

Suppose a frontend team bumps a charting library. The root lockfile changes, so the install layer for your billing API, your queue worker and your auth service is invalidated. Each one reinstalls everything from scratch, even though none of them imports that library. Each image also ships packages it never loads, such as React, build tooling and test runners. That inflates image size, slows pulls during scale-out and widens the surface your vulnerability scanner reports on.

The per-app approach replaces the root files with two generated artifacts per application:

  • A generated package.json that lists only the external packages the app actually imports. Nx derives this list from the project graph.
  • A pruned lockfile that contains only those packages and their transitive dependencies, with the same resolved versions as the root lockfile.

When these files are the input to the install layer, a dependency change invalidates only the images that depend on it.

This pattern suits backend and platform engineers who run several independently deployed Node services from one Nx workspace. In that setup, image build time and image size multiply across services and across CI runs.

# apps/billing-api/Dockerfile
# Build context: workspace root, after `npx nx build billing-api`
# with generatePackageJson enabled for the project's build target.

FROM node:20-alpine
WORKDIR /app

# Anti-pattern: keying the install layer on the root manifest.
# Any dependency bump in any project changes these bytes, so this
# layer and every layer after it rebuild for every service.
#   COPY package.json package-lock.json ./
#   RUN npm ci

# Per-app pattern: copy only the generated manifest and pruned
# lockfile. They list just the packages billing-api imports (plus
# their transitive deps), pinned to the root lockfile's versions.
# A charting-library bump in a frontend app leaves them unchanged,
# so Docker reuses the cached install layer below.
COPY dist/apps/billing-api/package.json dist/apps/billing-api/package-lock.json ./

# Install exactly what the pruned lockfile pins, no dev tooling.
RUN npm ci --omit=dev

# Copy the built output last; code changes invalidate only this layer.
COPY dist/apps/billing-api/ ./

ENV NODE_ENV=production
CMD ["node", "main.js"]

A per-service Dockerfile that keys the npm install layer on the app's generated package.json and pruned lockfile instead of the root files, so unrelated dependency bumps elsewhere in the workspace no longer invalidate its cache.

How Nx infers an app's dependencies from the project graph

The project graph has the information this needs. When Nx builds the graph, it parses each project's source files and records every import or require whose specifier is a string literal. Imports of workspace libraries become edges to other projects. Bare package specifiers become edges to npm: nodes. A build executor with generatePackageJson enabled walks this graph from the app, including its transitive workspace libraries, and collects every external package it reaches. Each package's version comes from the root package.json. The executor then filters the root lockfile down to those packages and their transitive dependencies. The resulting pruned lockfile resolves to exactly the versions your workspace already tested.

Flow: app and library source → project graph (npm: nodes) → build executor → dist/apps/api (bundle, generated package.json, pruned lockfile) → Docker deps stage (install from lockfile) → runtime stage (copy node_modules and bundle) → final image

Static analysis only sees what is written literally in the source. It misses packages in these cases:

  • Dynamic requires and string-built paths: for example, require(`./drivers/${name}`) or a driver name read from configuration.
  • Peer dependencies that a library expects the host app to provide.
  • Plugins and config-loaded packages: an ORM, logger or test runner that loads packages by name from a config file.
  • Native modules and runtime-only packages: optional bindings such as pg-native, or packages loaded only through an environment variable.

To declare the missing ones, keep a package.json in the app's project root and list them under dependencies. The generator uses that file as its base and adds the inferred packages to it, so explicit entries survive every rebuild. Confirm that your executor merges this file by inspecting the generated output once.

+---------------------+   +-------------------------+
| apps/api source     |   | apps/api/package.json   |
| libs/* source       |   | (explicit deps: peers,  |
+----------+----------+   |  plugins, native, etc.) |
           |              +------------+------------+
           | literal import/require    |
           v                           |
+---------------------+                |
| Project graph       |                |
| api -> libs -> npm: |                |
+----------+----------+                |
           |                           |
           v                           v
+----------------------------------------------+
| Build executor (generatePackageJson)         |
| inferred npm: nodes + explicit entries,      |
| versions from root package.json + lockfile   |
+----------------------+-----------------------+
                       |
                       v
+----------------------------------------------+
| dist/apps/api                                |
| bundle | package.json | pruned lockfile      |
+----------+-----------------------------------+
           |
           v
+---------------------+     +------------------+
| Docker deps stage   |---->| Runtime stage    |
| install from lock   |     | node_modules +   |
+---------------------+     | bundle -> image  |
                            +------------------+

Literal imports become project graph edges. The build executor merges the inferred npm packages with entries declared in the app's own package.json and writes a generated package.json and pruned lockfile. Docker installs from that lockfile and copies the result into the runtime image.

Enabling generatePackageJson and inspecting the dist output

Open the app's project.json and find the build target. In its options, set generatePackageJson to true. Webpack, esbuild and the tsc-based Node executors all accept a flag with this purpose, but the name, its default value and whether a lockfile is emitted alongside it differ by plugin, package manager and release. Read the executor schema for the plugin version you actually have installed. Don't rely on a blog post or an older example.

Run the build and look in the app's output directory under dist. You should see two files next to the compiled bundle:

  • package.json lists only the third-party packages the project graph traced to this app, pinned to the versions resolved in the root lockfile.
  • A pruned lockfile (package-lock.json, yarn.lock or pnpm-lock.yaml) contains only that subset and its transitive dependencies.

Diff the pruned lockfile against the root one. If it is nearly as large as the root file, a broad import is probably pulling in more than you expect. This is the file the Docker install layer will cache on, so any extra entry costs you cache misses later.

Internal workspace libraries need separate handling.

  • Bundled libraries are compiled straight into the app's output by webpack or esbuild. They never appear as dependencies and need no install step.
  • Buildable or publishable libraries that the executor leaves external show up as dependencies on packages that no registry can serve. To fix this, copy their built output into the image and point the dependency at a local path, or publish them to a private registry first. Without one of these, the install step inside the container will fail.
#!/usr/bin/env bash
# Build one Node app and inspect what generatePackageJson emitted.
# Assumes an app named "api", npm as the package manager, and
# generatePackageJson: true already set in apps/api/project.json.
set -euo pipefail

APP=api
OUT=dist/apps/$APP   # check outputPath in project.json; layouts differ

# 1. Confirm the option name your installed plugin accepts.
#    Search the executor schemas shipped in node_modules, not old examples.
find node_modules/@nx -path '*executors*' -name schema.json \
  -exec grep -l 'generatePackageJson' {} +

# 2. Build the app.
npx nx build "$APP"

# 3. The generated manifest should list only deps traced to this app.
ls "$OUT"
cat "$OUT/package.json"

# 4. Compare the pruned lockfile with the root one.
#    Near-equal sizes usually mean a broad import is pulling in extra packages.
wc -l package-lock.json "$OUT/package-lock.json"

# 5. Find internal workspace libs left external (scope is a placeholder).
#    These cannot come from a public registry: copy their built output into
#    the image and use a local path, or publish them to a private registry.
grep -n '"@myorg/' "$OUT/package.json" || echo "No external workspace libs found"

A shell walkthrough that finds the option name in the installed executor schemas, builds one app, inspects the generated package.json and pruned lockfile in dist, and flags internal workspace libraries that the image will still need to resolve.

Writing a multi-stage Dockerfile with a cached install layer

With a self-contained dist/apps/<app> directory, the Dockerfile can be small and predictable. Use that directory as the build context, not the repository root. Docker sends the entire context to the daemon before running any instruction, so pointing it at the monorepo root uploads every app's sources, node_modules, and .git on every build. Add a .dockerignore in the dist folder that excludes node_modules, so a stray local install can't leak host-compiled binaries into the image.

The builder stage copies only package.json and the pruned lockfile, then runs a frozen install. Use npm ci, pnpm install --frozen-lockfile, or yarn install --immutable, whichever matches the workspace. Because those two files change only when the app's real dependencies change, this layer stays cached across most commits. The runtime stage then copies node_modules from the builder and the compiled output from the context.

The lockfile format has to match the package manager. Each manager reads only its own format and resolves differently. If you feed an npm lockfile to pnpm, the lockfile is ignored and resolution happens again. A frozen install refuses to proceed when package.json and the lockfile disagree. You want that failure: it means pruning drifted, and resolving fresh would ship versions nobody tested.

Native modules and platform-specific optional dependencies need care. These packages select prebuilt binaries per OS, CPU, and libc. Run the install inside the image, on the target platform, never on the host. If you build arm64 images on x86 machines, pass --platform explicitly, or run npm rebuild in the builder stage.

# Build context: dist/apps/api (generatePackageJson: true + pruned lockfile)
# Build: docker build --platform linux/amd64 -t api:local dist/apps/api
# dist/apps/api/.dockerignore should contain: node_modules

FROM node:20-bookworm-slim AS deps
WORKDIR /app
# Toolchain for native addons that lack a prebuilt binary for this platform
RUN apt-get update \
 && apt-get install -y --no-install-recommends python3 make g++ \
 && rm -rf /var/lib/apt/lists/*
# Copy only the manifest and pruned lockfile so this layer stays cached
# until the app's real dependencies change
COPY package.json package-lock.json ./
# Fails if package.json and the lockfile disagree (pruning drift)
RUN npm ci --omit=dev

FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Same base image as deps, so native binaries match the runtime OS, CPU and libc
COPY --from=deps /app/node_modules ./node_modules
# Compiled output from the dist context; host node_modules excluded by .dockerignore
COPY . .
USER node
CMD ["node", "main.js"]

A two-stage Dockerfile built from dist/apps/api that installs from the pruned npm lockfile with npm ci on the target platform, then copies node_modules and compiled output into a slim runtime image.

Wiring per-app docker targets into nx affected

Docker knows nothing about Nx. If someone runs docker build directly, it copies whatever is in dist/ at that moment. That might be last week's output, or nothing. The fix is to make the image build an Nx target. Add a docker-build target to each app's project.json using the nx:run-commands executor, and declare "dependsOn": ["build"]. Nx then runs the app's build first. The build is a cached target, so this costs almost nothing when the source hasn't changed, and it rebuilds correctly when it has. Set "cache": false on docker-build itself. Its result is an image in a registry, not files Nx can restore, so a cache hit would skip a push you actually needed.

In CI, run nx affected -t docker-build with --base set to the last successful commit on main and --head set to the current commit. Nx compares the two against the project graph. A change to a shared library marks every app that imports it as affected. A change confined to one app affects only that app. Untouched services are not rebuilt or pushed.

Tag each image with the full commit SHA, passed into the command from a CI environment variable. A SHA tag maps back to exactly one source tree, so rollbacks and incident debugging don't depend on a mutable tag like latest. You can push latest as well, but deploy by SHA.

The time savings come from two places. Each pipeline does less work, and engineers get feedback on their own service without waiting for images of unrelated apps.

Verifying the image and measuring the difference

A smaller, faster image is only an improvement if it still starts. Before relying on the new setup, work through these checks.

  • Diff the generated package.json against real imports. Open the manifest in the app's dist folder and compare its dependencies with the packages the app's source actually imports. Anything imported but missing means the project graph could not see it. Common causes are dynamic require() calls, plugins loaded by name from config, and peer dependencies. Add those packages explicitly rather than hoping they arrive transitively.
  • Run the start command in CI as a smoke test. Build the image, then run it with its real entrypoint and fail the job if the process exits within a few seconds or a health endpoint does not respond. Static analysis misses modules that are resolved at runtime. Only executing the code catches those.
  • Watch for pruned-lockfile drift. When someone updates the root lockfile or changes the package manager version, regenerate the pruned lockfile and rebuild. A frozen install inside the image will fail if the two disagree. Treat that failure as useful: it is cheaper to hit in CI than in production.
  • Measure on your own repo. Compare docker image ls sizes before and after, and check build logs to confirm the install layer is reused when only source files change.

Illustrative estimate: assume a root manifest with 400 dependencies, of which one service uses 60. If install time scales roughly with package count, that service's install layer shrinks by about 85%. It also gets rebuilt only when one of those 60 packages changes, not when any of the 400 does.

When to avoid per-app images, alternatives and cost trade-offs

That isolation has a price, and for some repositories the price is higher than the benefit. If you run three or four services that always deploy together, share most of their dependencies, and are owned by one team, a single shared image is usually simpler. One image means one build, one scan, and one tag to roll back.

Concern Shared root image Per-app image, pruned lockfile Single bundled file
Cache isolation None; any dependency change rebuilds all Per app Per app, but rebuilt on most source changes
Image size Largest Medium Smallest
Build, scan, push, cleanup work One pipeline One per affected app One per affected app
Native addons, source maps Work as-is Work as-is Addons must ship beside the bundle; maps must be emitted and shipped
License and vulnerability scanning Easy; full lockfile Easy; pruned lockfile per image Harder; package metadata is often stripped
Version alignment across apps Enforced Enforced by the single root lockfile Enforced at build time; drift is invisible in the image

Illustrative estimate (assumed numbers, not a benchmark). Images per day = apps × fraction affected per merge × merges per day. Assume 12 apps, 25% affected per merge, and 20 merges a day. That gives 12 × 0.25 × 20 = 60 images a day, compared with 20 for a shared image. Each of those images is scanned, pushed, and stored, so it also needs a retention rule.

The development cost is mostly engineering time spent on CI configuration, retention policies, and triaging scan results across many images. The infrastructure cost is CI minutes and registry storage. Per-app images pay off when the install time you save across those 60 builds outweighs the overhead of running them.

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 monorepo, 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.