What goes wrong when every app image copies the whole workspace
The default Dockerfile in many Nx workspaces copies the root package.json and the full lockfile, runs a clean install, then builds one app. It works, but every service image inherits the dependency set of the entire workspace.
That causes three distinct problems.
Every service installs everything. A small API that needs an HTTP framework and a database driver also gets the frontend toolchain, test runners, the ORM another team uses, and native modules compiled for features it never calls. Install time and image size grow with the workspace, not with the app.
The attack surface widens. Every package in node_modules can show up in a vulnerability scan and can be loaded at runtime. When a CVE lands in a dependency used by only one app, every image gets flagged. You then patch and redeploy services that never imported the affected code.
Cache invalidation spreads. Docker caches the install layer by hashing its inputs. If the input is the root lockfile, then bumping a dev dependency for one library invalidates the install layer for every service image. On an active workspace with frequent lockfile changes, the cache rarely survives a day.
The alternative is to have Nx emit a package.json and a pruned lockfile for each app, containing only what that app's dependency graph needs. Make those two files the only dependency contract the Dockerfile sees. This guide is for backend and platform engineers running several deployable Node apps in one Nx workspace. The approach pays off most when services have divergent dependency sets and the lockfile changes often.
How Nx works out what an app depends on
Nx answers "what does this app need?" with its project graph. It builds the graph from two inputs. The first is static analysis of source files: every import and require with a string literal is resolved either to a workspace project, through the TypeScript path mappings, or to an external npm package. The second is explicit configuration, such as implicitDependencies in a project's config, which records edges that no import statement reveals. Section 6 covers those blind spots.
External package nodes in the graph only identify package names. To pin versions, Nx reads the root lockfile and keeps each resolved package from the app's import closure along with its transitive dependencies. That subset becomes the generated package.json and a pruned lockfile. Because the versions come from the root lockfile, the pruned lockfile resolves to what CI already tested, not to fresh ranges.
Flow: source imports → project graph (internal libs + external packages) → root lockfile lookup → pruned package.json + lockfile → Docker deps stage (install) → runtime stage (bundle + node_modules)
How internal libraries are handled depends on their type:
| Library type | How the app consumes it | Effect on generated package.json |
|---|---|---|
| Non-buildable (default) | Source compiled into the app bundle | Not listed; only its external imports are added |
| Buildable | Built separately, then referenced through its output | Its external deps are still traced; the lib itself is usually bundled |
| Publishable | Can be consumed by its npm package name | May appear as a versioned dependency if the bundler treats it as external |
The default case is the one that keeps images small: shared code becomes part of the app's JavaScript, and node_modules holds only third-party packages.
+---------------------+ +---------------------------+
| App + lib sources | | Project config |
| import / require | | implicitDependencies |
+----------+----------+ +-------------+-------------+
| static analysis |
+----------------+--------------+
v
+-------------------------------------+
| Project graph |
| internal libs -> bundled into app |
| external pkgs -> names only |
+------------------+------------------+
| package names
v
+-------------------------------------+
| Root lockfile lookup |
| pin versions + transitive deps |
+------------------+------------------+
v
+-------------------------------------+
| Pruned package.json + lockfile |
| app bundle built alongside |
+------------------+------------------+
|
+--------------+
v
+--------------------------+ +----------------------------+
| Docker deps stage | | Docker runtime stage |
| install from pruned lock |---->| app bundle + node_modules |
+--------------------------+ +----------------------------+
Nx builds the project graph from source imports and explicit config, then pins the app's external packages against the root lockfile to emit a pruned package.json and lockfile. The Docker deps stage installs from that pruned lockfile, and the runtime stage combines the app bundle with node_modules.
Generating a per-app package.json and pruned lockfile
The pruned node_modules doesn't need to be built by hand. Nx's Node build executors, such as @nx/webpack:webpack and @nx/esbuild:esbuild, accept a generatePackageJson option. Set it to true in the options of the app's build target in project.json. On the next nx build api, the executor uses the project graph from the previous section to write two extra files into the build output directory (typically dist/apps/api):
- A
package.jsonthat lists only the third-party packages the bundled code still imports at runtime. Versions are taken from the workspace, so the app does not drift from what was tested. - A pruned lockfile in your package manager's format:
package-lock.json,yarn.lock, orpnpm-lock.yaml. It is cut down to those packages and their transitive dependencies.
The pair matters. A trimmed package.json without a matching lockfile would force a fresh resolution inside the image, and that resolution can pick different transitive versions than your workspace uses. With both files present, the image can run a frozen install (npm ci or the equivalent) and reproduce the tested dependency tree exactly.
Inspect the output after the first build instead of assuming it is correct. Running jq .dependencies dist/apps/api/package.json prints the dependency list. Read it with two questions in mind. Is anything there that should be dev-only? Is anything missing that the app loads by string name? Missing entries won't show up at build time. They show up as a crash when the container starts.
#!/usr/bin/env bash
set -euo pipefail
PROJECT_JSON=apps/api/project.json
OUT=dist/apps/api
# 1. Enable generatePackageJson on the build target (@nx/webpack:webpack or @nx/esbuild:esbuild).
# This is the same as adding "generatePackageJson": true under targets.build.options by hand.
jq '.targets.build.options.generatePackageJson = true' "$PROJECT_JSON" > "$PROJECT_JSON.tmp"
mv "$PROJECT_JSON.tmp" "$PROJECT_JSON"
# 2. Build. The executor writes package.json and a pruned lockfile next to the bundle.
npx nx build api
# 3. Confirm that both files exist. The lockfile name depends on your package manager.
ls "$OUT/package.json"
ls "$OUT"/package-lock.json "$OUT"/yarn.lock "$OUT"/pnpm-lock.yaml 2>/dev/null || {
echo "No pruned lockfile in $OUT: a frozen install in the image will fail" >&2
exit 1
}
# 4. Review the runtime dependency list yourself. Check for two problems:
# - Dev-only tools (test runners, linters, type packages) that slipped in.
# - Packages the app loads by string name (plugins, DB drivers) that are missing.
jq .dependencies "$OUT/package.json"
# 5. A runtime manifest should not carry devDependencies at all.
jq '.devDependencies // "none"' "$OUT/package.json"
This script turns on generatePackageJson for the api project, builds it, checks that the generated package.json and pruned lockfile are both in dist/apps/api, and prints the dependency list for manual review.
A multi-stage Dockerfile built around the pruned output
Once the manifest is trustworthy, the Dockerfile treats it as the only description of what the app needs. Run the Nx build in CI or in an early builder stage, and point everything after that at the build output directory for that one app, not at the workspace root.
Install stage
Start a dedicated install stage. Copy in only the generated package.json and the pruned lockfile, then run a frozen install with dev dependencies excluded: npm ci --omit=dev, or the frozen-lockfile production install for pnpm or Yarn. A frozen install fails if the manifest and lockfile disagree. That is what you want, because a drifted lockfile should break the build rather than quietly resolve new versions.
Packages with native addons may compile during install or run postinstall scripts that download prebuilt binaries. Don't disable scripts globally to save time unless you have checked that nothing depends on them. The compilers and headers those builds need (Python, make, a C++ toolchain) belong in this stage only.
Runtime stage
The runtime stage copies node_modules from the install stage, so both stages must use the same base image family. A binary compiled against glibc on a Debian-based image will fail to load on a musl-based Alpine image, and the reverse is also true. That failure appears at require time, not at build time.
Layer order
Copy the built JavaScript last. The dependency layers depend only on the manifest and lockfile, so Docker reuses them from cache whenever a commit changes application code but not dependencies. Most commits fall into that category.
# Build context is the Nx output for one app, e.g. dist/apps/api, produced in CI
# with a generated package.json and pruned package-lock.json alongside main.js.
# Both stages use the same base so native addons match the runtime libc (glibc here).
ARG NODE_IMAGE=node:22-bookworm-slim
# --- Install stage: toolchains live only here ---
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
# Compilers and headers for node-gyp builds of native addons
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Only the manifest and lockfile, so this layer's cache key ignores code changes
COPY package.json package-lock.json ./
# Frozen install: fails if the lockfile and package.json disagree.
# Lifecycle scripts stay enabled so postinstall steps for native packages still run.
RUN npm ci --omit=dev
# --- Runtime stage: no compilers, no package manager cache ---
FROM ${NODE_IMAGE} AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Dependencies first. This layer is reused when only app code changes.
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Built JavaScript last, so code-only commits rebuild just this layer.
# Keep node_modules out of the context with a .dockerignore entry.
COPY --chown=node:node . .
USER node
CMD ["node", "main.js"]
A two-stage Dockerfile, run against one app's Nx build output. It installs production dependencies from the pruned lockfile in a toolchain-equipped stage, copies node_modules into a runtime stage on the same glibc base, and adds the built code in the final layer so the dependency layers stay cached.
Driving per-app image builds and verification from Nx and CI
Building only what changed
Give each deployable app a docker-build target whose dependsOn includes that project's own build target. Nx then treats the image as one more node in the project graph. In CI, nx affected -t docker-build builds an image only when the app, one of its workspace libraries, or its external dependencies changed since the base commit. A change to a shared logging library rebuilds the services that import it. A change to the billing service leaves the others untouched. Push only the images that were built, and tag them with the commit SHA so deploy tooling can see which services actually moved. The time saved comes from avoiding builds and from avoiding the review and rollout of services nobody touched.
Verifying the pruned output
Each check catches a different failure:
- Diff the generated manifest. Store or recompute the generated
package.jsonfor the base commit and compare it with the current one. A dependency that appears or disappears when nobody edited an import usually means a graph change worth reading, such as a library that started importing something heavy. - Install frozen inside the image. Use
npm ci,pnpm install --frozen-lockfile, oryarn install --immutable. If the pruned lockfile and the generated manifest disagree, the install should fail the build. It should not quietly resolve new versions. - Smoke-test the container. Run the image, wait for it to bind its port or answer a health endpoint, and fail if it exits early. A dependency loaded through a dynamic
requireor a plugin loader often passes every build step and fails only when the process starts.
#!/usr/bin/env bash
# CI step: build affected images, smoke-test each one, then push only those.
set -euo pipefail
BASE="${NX_BASE:-origin/main}"
HEAD_REF="${NX_HEAD:-HEAD}"
REGISTRY="registry.example.com/acme"
# Assumes each app's docker-build target tags its image as $REGISTRY/<app>:$IMAGE_TAG.
export IMAGE_TAG="$(git rev-parse --short HEAD)"
# docker-build dependsOn the app's build target, so Nx builds first.
# The Dockerfile runs a frozen install (npm ci / --frozen-lockfile / --immutable),
# so a pruned lockfile that disagrees with the generated manifest fails here.
npx nx affected -t docker-build --base="$BASE" --head="$HEAD_REF"
for app in $(npx nx show projects --affected --withTarget docker-build --base="$BASE" --head="$HEAD_REF"); do
image="$REGISTRY/$app:$IMAGE_TAG"
cid=$(docker run -d -p 3000:3000 "$image")
ok=false
# Missing modules usually surface only when the process starts, so wait for health.
for _ in $(seq 1 30); do
# Container exited early: almost always a startup crash such as a missing module.
[ "$(docker inspect -f '{{.State.Running}}' "$cid")" = "true" ] || break
if curl -fsS >/dev/null; then ok=true; break; fi
sleep 1
done
if [ "$ok" != "true" ]; then
echo "smoke test failed for $app" >&2
docker logs "$cid" >&2 || true
docker rm -f "$cid" >/dev/null
exit 1
fi
docker rm -f "$cid" >/dev/null
# Push only images that were rebuilt and passed, tagged with the commit SHA.
docker push "$image"
done
A CI script that uses nx affected to build only the images whose project graph changed and relies on the frozen install to fail mismatched lockfiles; it then smoke-tests each container's health endpoint before pushing it with a commit-SHA tag.
Declaring dependencies static analysis cannot see
The cause is that Nx builds the project graph by reading import and require statements it can resolve without running code. A string literal like require('pg') produces an edge to that package. A computed path like require(`./drivers/${name}`), a plugin loader that resolves names from a config file, or an ORM that loads its database driver by name at connection time produces no edge. The generated package.json leaves those packages out, the pruned lockfile leaves them out, and the install stage succeeds because nothing in the image asks for them. The bundler does not complain either, since it never saw the import. The first sign of trouble is Cannot find module when that code path runs, which may be at boot or not until the first request that reaches the driver.
CLI tools are a variant of the same problem. A migration runner or a binary started through child_process is never imported, so Nx has no reason to treat it as a runtime dependency.
The fix is to state these dependencies somewhere the pruning step reads:
- Workspace libraries loaded by path: add them to the app's
implicitDependenciesinproject.jsonso the graph includes them. - npm packages, when projects have their own manifests: list them under
dependenciesin the app's package.json. The generated manifest takes them into account. - Anywhere else: add a side-effect import of the package in the app's entry file. This puts it in the graph through ordinary static analysis.
Whichever method you use, add a short comment explaining why the entry is there, so a future cleanup does not remove it as unused.
When to avoid pruning, alternatives, and the cost trade-offs
Pruning is a poor fit when the root lockfile relies on features the pruner may not carry over faithfully: overrides or resolutions, patched dependencies, aliased packages, or workspace protocol references. The result can be a lockfile that a frozen install rejects. Worse, it can install cleanly but resolve a transitive package differently than the root does. Peer and optional dependencies are a related risk. With a smaller tree, the package manager may choose a different peer version or skip an optional platform binary that local development always had. You also lose the ability to build an image from a clean checkout alone, because the Dockerfile now expects Nx build output to exist.
| Approach | Image contents | Main cost |
|---|---|---|
| Copy full workspace | Every dependency of every app | Large images, cache busts on any lockfile change |
| Pruned per-app lockfile | Only the app's dependencies | Pruning edge cases, build depends on a prior Nx step |
| Bundle into one file | No node_modules |
Breaks native addons and path-relative file reads, and scanners cannot see package versions |
| Publish internal libraries | Ordinary external installs | Versioning, a registry, and release overhead |
Illustrative estimate (assumed numbers, not a benchmark)
Assumptions:
- 8 apps, each using about 40% of root dependencies.
- 10 lockfile changes per month.
- 1 in 10 changes breaks a pruned install.
- 2 hours to diagnose each break.
Monthly maintenance = changes × break rate × hours per break = 10 × 0.1 × 2 = 2 engineer-hours per month.
Weigh that number against the registry storage, pull time, and scan noise you save from roughly 60% fewer packages per image. Then substitute your own inputs into the formula.
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 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.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
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.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- 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.