Why does a repo-root Docker context slow down every service image?

When each service's Dockerfile uses the repository root as its build context, every image starts from the same inputs: the root package.json and the root lockfile. A typical install layer copies those two files and runs the package manager. Docker keys that layer's cache on the checksum of the copied files. A version bump in a frontend app, a new dev tool, or a transitive update pulled in by an unrelated library changes the root lockfile, and the install layer is invalidated for every service, including services that don't use the changed package at all.

The second cost is context transfer. Before any instruction runs, the builder receives the whole context directory, minus what .dockerignore excludes. In a monorepo that means other apps' sources, shared libraries, docs, and sometimes stale dist folders. Their contents also feed the checksums of any broad COPY instruction.

You can see both effects in normal build output. With BuildKit, the load build context step prints a transferring context line with the size sent. If that number is similar for every service image, each one is shipping the whole repo. Then compare two builds made before and after an unrelated dependency change. The install step will show as executed instead of CACHED, and every step after it reruns.

This hurts most for backend and platform teams running several Node services from one Nx workspace. One lockfile change multiplies into a full dependency install per image on every CI run. The time goes to reinstalling packages that never changed, and it scales with the number of services, not with the size of the change.

#!/usr/bin/env bash
# Diagnose a repo-root build context: context size per image, then install-layer cache misses.
# Assumes each service has apps/<service>/Dockerfile and is built with the repo root as context.
set -euo pipefail

SERVICES=(orders-api billing-api notifications-worker)

# 1) Context size per service. BuildKit's plain progress output goes to stderr.
#    If every service reports roughly the same size, each image is shipping the whole repo.
for svc in "${SERVICES[@]}"; do
  docker build --progress=plain -f "apps/${svc}/Dockerfile" -t "${svc}:diag" . \
    > "before-${svc}.log" 2>&1
  echo "${svc}: $(grep 'transferring context' "before-${svc}.log" | tail -n 1)"
done

# 2) Make a dependency change none of these services uses, for example by bumping
#    a frontend-only package in apps/web, so that only the root lockfile changes.
#    Then rebuild the same services without touching their own source.
read -r -p "Change a frontend-only dependency, then press Enter to rebuild... "

for svc in "${SERVICES[@]}"; do
  docker build --progress=plain -f "apps/${svc}/Dockerfile" -t "${svc}:diag" . \
    > "after-${svc}.log" 2>&1
  echo "--- ${svc}"
  # Before: the install step shows as CACHED.
  # After: it shows as executed, along with every step after it.
  # Adjust the pattern to match your install command (npm ci, pnpm install, yarn install).
  grep -E 'CACHED|RUN (npm ci|pnpm install|yarn install)' "after-${svc}.log" || true
done

A diagnostic script that prints the build-context size BuildKit reports for each service image, then rebuilds after an unrelated lockfile change to show the install step being re-run instead of reported as CACHED.

How does Nx know which dependencies a service actually uses?

Nx sidesteps that cost by working out, before Docker is involved, which packages a service actually needs. The core limit comes first: Nx only sees what is statically imported. A require(someVariable), a plugin loaded by name from a config file, or a database driver an ORM resolves at runtime is invisible to it.

Within that limit, the mechanism is the project graph. Nx parses the service's source files and records every import and require with a literal specifier. Specifiers that resolve to workspace libraries become edges to those projects, and Nx follows them transitively, so a package imported three libraries deep still counts. Specifiers that resolve to node_modules become edges to external npm: nodes in the same graph.

That graph supplies the package names. The root lockfile supplies the exact resolved versions and each package's own transitive dependency tree. Combining the two lets Nx emit a package.json that lists only the service's direct dependencies and a lockfile that keeps only the entries those dependencies reach. Neither file is built by guessing, and nothing outside the service's subgraph appears in them.

 root lockfile ──┐
                 ├──> nx build <service> ──> dist/apps/<service>/
 project graph ──┘                            ├─ compiled code
                                              ├─ package.json   (pruned)
                                              └─ lockfile       (pruned)
                                                       │
                                                       v
                                            docker build context
                                                       │
                                                       v
                                                 runtime image

Because the output directory is self-contained, it can serve as the entire build context. A lockfile change for an unrelated service no longer reaches this image.

┌──────────────────┐   ┌────────────────────────────────────┐
│  root lockfile   │   │           project graph            │
│ exact versions + │   │ service ─> libs (transitive)       │
│ transitive deps  │   │        └─> npm: nodes (static only)│
└────────┬─────────┘   └─────────────────┬──────────────────┘
         │                               │
         └───────────────┬───────────────┘
                         v
              ┌─────────────────────┐
              │ nx build <service>  │
              └──────────┬──────────┘
                         v
        ┌──────────────────────────────────┐
        │ dist/apps/<service>/             │
        │  ├─ compiled code                │
        │  ├─ package.json  (pruned)       │
        │  └─ lockfile      (pruned)       │
        └────────────────┬─────────────────┘
                         v
        ┌──────────────────────────────────┐
        │ docker build context             │
        └────────────────┬─────────────────┘
                         v
        ┌──────────────────────────────────┐
        │ runtime image                    │
        └──────────────────────────────────┘

The project graph supplies the package names a service statically imports, directly or through workspace libraries. The root lockfile supplies their resolved versions. The Nx build combines the two into dist/apps/, which becomes the Docker build context for the runtime image.

Generating a pruned package.json and lockfile with generatePackageJson

The pruned manifest comes from the build target itself. On a service that uses @nx/webpack:webpack or @nx/esbuild:esbuild, set "generatePackageJson": true in the build target's options in apps/orders-api/project.json. The bundler already resolves every import to produce the bundle, and Nx uses that project graph to write a package.json containing only the third-party packages the service reaches. It also writes a matching lockfile.

After nx build orders-api, expect dist/apps/orders-api/ to hold main.js, any source maps or assets, a package.json, and a lockfile named for your package manager: package-lock.json, yarn.lock, or pnpm-lock.yaml. The option name and the lockfile behavior have changed between Nx releases and differ by package manager, so check the Nx docs for the version you have installed before relying on them.

Inspect the artifacts before you trust them:

  • Dependency diff: Run diff <(jq -S .dependencies package.json) <(jq -S .dependencies dist/apps/orders-api/package.json). Every line should be a removal. A package that appears only in the pruned file signals a misconfiguration.
  • No workspace libraries: Run jq -r '.dependencies | keys[]' dist/apps/orders-api/package.json | grep '^@myorg/'. This should print nothing. Internal libraries belong inside main.js. If one is listed, npm ci in the image will try to fetch it from a registry and fail. With esbuild, check that bundling is enabled.
  • Pinned versions match: For npm, run jq -r '.packages["node_modules/express"].version' against both lockfiles and compare the output. A mismatch means the image would run code you never tested.
#!/usr/bin/env bash
# Verify the pruned artifacts from `nx build orders-api` before building an image.
# Assumes npm (package-lock.json with a "packages" map) and jq on PATH.
set -euo pipefail

OUT=dist/apps/orders-api
SCOPE='@myorg/'   # npm scope used by your workspace libraries

# Expect main.js, maps/assets, package.json and package-lock.json here.
ls -1 "$OUT"

# 1. Dependency diff: the pruned set should be a subset of the root set.
#    Print the full diff for review, then fail on names that exist only in the pruned file.
diff <(jq -S '.dependencies // {}' package.json) <(jq -S '.dependencies // {}' "$OUT/package.json") || true
extra=$(comm -13 \
  <(jq -r '.dependencies // {} | keys[]' package.json | sort) \
  <(jq -r '.dependencies // {} | keys[]' "$OUT/package.json" | sort))
if [ -n "$extra" ]; then echo "Only in pruned package.json:"; echo "$extra"; exit 1; fi

# 2. Workspace libraries must be bundled into main.js, not listed as dependencies.
if jq -r '.dependencies // {} | keys[]' "$OUT/package.json" | grep "^$SCOPE"; then
  echo "Workspace library listed as a dependency; npm ci would try to fetch it"; exit 1
fi

# 3. Every pinned version in the pruned lockfile must match the root lockfile.
pins() {
  jq -r '.packages | to_entries[]
    | select(.key != "" and .value.version != null)
    | "\(.key) \(.value.version)"' "$1" | sort
}
mismatch=$(comm -23 <(pins "$OUT/package-lock.json") <(pins package-lock.json))
if [ -n "$mismatch" ]; then echo "Pruned lockfile entries not in root lockfile:"; echo "$mismatch"; exit 1; fi

echo "Pruned artifacts look consistent with the root workspace"

A bash check that lists the build output and then verifies three things for an npm workspace. The pruned dependencies must be a subset of the root dependencies. No workspace library may be listed as a dependency. Every version pinned in the pruned lockfile must match the root lockfile.

How do you write a Dockerfile that uses only the service's output as context?

With the pruned files in dist/apps/<service>, that directory becomes the entire build context. The Dockerfile never sees the rest of the monorepo, so a stray COPY . . cannot pull in other apps, .git, or the root node_modules.

Order the instructions so the slow step depends on as few files as possible:

  1. Start from a Node base image and set a working directory.
  2. Copy only package.json and the pruned lockfile.
  3. Run a frozen production install: npm ci --omit=dev for npm. For Yarn Classic, use yarn install --frozen-lockfile --production. For pnpm, use pnpm install --frozen-lockfile --prod. Flag names have changed across releases, so check them against your package manager version.
  4. Copy the compiled JavaScript and set the start command.

Docker reuses a layer only if the files copied into it are unchanged. Application code changes on almost every commit. This service's pruned manifest and lockfile change only when its own dependency set changes. A version bump in some other app's dependencies leaves them byte-identical, so the install layer stays cached and most rebuilds only redo the final COPY.

Native and platform-specific packages, such as bcrypt or sharp, must be installed inside the image. Never copy node_modules from the host. Binaries compiled or downloaded for macOS, or for a different libc, will fail at runtime in a Linux container. The pruned lockfile keeps that in-image install cheap and deterministic. It resolves only the packages this service needs, at pinned versions, so the install is fast and repeatable.

Build with the dist path as context and point -f at the Dockerfile, wherever it lives:

docker build -f apps/api/Dockerfile -t api:local dist/apps/api

BuildKit's transferring context line now reports the size of the compiled output and two manifest files instead of the whole repository.

# apps/api/Dockerfile
# Build context is dist/apps/api (Nx output with the pruned package.json
# and package-lock.json), not the monorepo root:
#   docker build -f apps/api/Dockerfile -t api:local dist/apps/api

FROM node:20-bookworm-slim

WORKDIR /app
ENV NODE_ENV=production

# 1. Copy only the pruned manifest and lockfile. This layer's cache key
#    changes only when this service's own dependency set changes.
COPY package.json package-lock.json ./

# 2. Frozen production install from the pruned lockfile.
#    Native packages (bcrypt, sharp, ...) are built or downloaded here,
#    for this image's OS and libc, never copied from the host.
#    Yarn Classic: yarn install --frozen-lockfile --production
#    pnpm:         pnpm install --frozen-lockfile --prod
#    (verify flag names for your package manager version)
RUN npm ci --omit=dev

# 3. Copy the compiled output last. It changes on most commits, so only
#    this layer and those after it are rebuilt.
#    Add node_modules to a .dockerignore next to the Dockerfile
#    (apps/api/Dockerfile.dockerignore) so a stray host install in
#    dist/apps/api can never overwrite the in-image one.
COPY . .

USER node
CMD ["node", "main.js"]

A per-service Dockerfile that uses dist/apps/api as its whole build context, installs production dependencies from the pruned lockfile before copying compiled code, and so keeps the install layer cached until the service's own dependencies change.

Should you build with Nx on CI or inside a multi-stage Dockerfile?

Option B looks self-contained, but it puts the large context back. The builder stage needs the whole workspace to run nx build, so it reads the repo root again. The size advantage only appears in the final image. As a sketch, Option B has three parts:

  • A builder stage, based on a Node image, copies the root package.json and lockfile and runs npm ci.
  • The same stage then copies the workspace source and runs npx nx build api. That emits the compiled output and the pruned manifests into dist/apps/api.
  • A slim runtime stage copies only dist/apps/api from builder with COPY --from=builder, runs npm ci --omit=dev against the pruned lockfile, and sets the entrypoint.
Approach Dockerfile alone reproduces image Build-stage context Cache behavior CI complexity
Baseline: ordered root-context Dockerfile, no pruning Yes Whole repo Install layer rebuilds when any project's dependencies change Lowest: one docker build
A: Nx build on the runner, dist/apps/<service> as context No, it depends on a prior nx build Compiled output plus two manifests Install layer keyed to the service's own pruned lockfile; Nx task cache reused on the runner Moderate: build, then pass the output path as context
B: Nx build in an earlier stage Yes Whole repo Builder install layer behaves like the baseline; runtime install keyed to the pruned lockfile; Nx cache is lost unless persisted Low in CI, more Dockerfile logic

The real trade is between Option A's fast, targeted rebuilds and Option B's guarantee that docker build works on any machine. Option A fits teams that already gate on affected Nx builds. Option B fits teams where external builders or auditors must be able to rebuild the image from the Dockerfile alone.

How do you rebuild only affected images and catch missing dependencies in CI?

Whichever option you chose, avoid rebuilding every image on every commit. Define a docker-build target on each service project with dependsOn: ["build"], and have it run docker build against the service's output directory through the nx:run-commands executor. CI then calls nx affected -t docker-build. Nx walks the project graph from the changed files, so a commit that only touches billing and its libraries leaves the other services' images alone. Builds get shorter, the review queue moves faster, and nobody waits on images that cannot have changed.

Skipping work is only safe if the images you do build actually run. The pruner reads static imports. It cannot see a dynamic require built from a string, a plugin loaded by name from config, or a peer dependency that something resolves at runtime. Add a smoke-test step after each docker-build and treat it as mandatory:

  1. Run the container detached.
  2. Poll its health endpoint with a bounded retry loop.
  3. On timeout or a non-2xx response, print docker logs and exit non-zero.

A worker with no HTTP surface can instead run a start-up command that imports its entry module and exits cleanly. This step catches modules the pruner missed. It also catches drift between nx serve and the container: a dependency that works locally because it is hoisted at the repo root but is absent from the pruned install.

One caveat: bumping a widely shared dependency, such as a framework or ORM, changes every dependent pruned lockfile. Every affected service then reinstalls from scratch. This is correct behavior, because each of those images now ships different code.

When should you avoid pruned lockfiles, and what does it cost?

Pruning trades image size for a new failure surface. Nx derives the dependency list from statically analyzable imports, so anything resolved at runtime is invisible to it: computed require() paths, plugin loaders, ORM drivers chosen by a config value, and packages invoked only from config files or CLI scripts. The image builds cleanly, and the failure appears only when that code path executes. The pruned lockfile is also regenerated rather than copied, so it can lose details that the root install encoded.

Risk Symptom Mitigation
Dynamic or config-only dependencies "Cannot find module" at runtime, often on a rarely used path Declare them in the project's package.json, and start the container in CI and exercise those paths
Peer and optional dependencies A different version resolves, or an optional native binary is missing Pin them as direct dependencies of the service
Overrides/resolutions, patches, workspace: refs, aliases A patched or overridden version reverts, or the install fails to resolve Diff resolved versions against the root lockfile in CI, and retest after any npm, yarn, pnpm or Nx upgrade
Bundled workspace libraries Stack traces point into bundle offsets Emit source maps and enable them at runtime
Extra build machinery Image failures require Nx knowledge to diagnose Document the pipeline and assign an owner

Pruning pays off in workspaces with many services and frequent dependency churn. In those repos, avoiding unrelated reinstalls saves time on every build. Workspaces with one or two services usually do better with a root-context Dockerfile that copies the manifests and lockfile first, installs, and copies source afterward. That ordering keeps the install layer cached and avoids running a second resolver.

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