✦ For everyone, free.

Practical knowledge for real and everyday life

Home

17.1.1.2 Dependency Pruning Practice

A focused guide to Dependency Pruning Practice, connecting core concepts with practical Docker and container operations.

Dependency pruning practice is the deliberate exclusion of development-only, build-only, and otherwise unnecessary dependencies from a final production image, distinct from base image minimalism since it addresses the application's own dependency tree rather than the underlying operating system layer, and it matters because most language ecosystems' package managers by default install considerably more than what a running application actually needs in production.

Distinguishing production from development dependencies

Most package managers support explicitly separating dependencies genuinely needed at runtime from those only needed during development or testing, and installing only the former in the final production image directly reduces both size and the surface of code actually present and potentially exploitable:

{
  "dependencies": { "express": "^4.18.0" },
  "devDependencies": { "jest": "^29.0.0", "eslint": "^8.0.0" }
}
npm ci --omit=dev
pip install --no-dev -r requirements.txt

Testing frameworks, linters, and build tooling have no legitimate reason to be present in a production runtime, and their continued inclusion represents pure, avoidable bloat with no corresponding benefit once the application has actually been tested and built.

Multi-stage builds as the natural enforcement mechanism

Performing a full development dependency install in an early build stage, then explicitly installing only production dependencies in the final stage, ensures the exclusion happens structurally rather than depending on remembering to pass the right flag at the right moment:

FROM node:20 AS build
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY package*.json .
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

This pattern installs the full dependency set, including development tools needed for the build process itself, in the build stage, while the final stage performs its own, separate, production-only install, ensuring development dependencies never have a path into the final image at all.

Auditing what is actually present in the final image

Directly inspecting an already-built image's dependency tree confirms whether pruning is actually working as intended, rather than assuming it based on the Dockerfile's apparent structure alone:

docker run --rm my-api:1.4.2 npm ls --all --omit=dev
docker run --rm my-api:1.4.2 du -sh /app/node_modules

A node_modules directory in the final image still containing testing frameworks or build tools despite an apparent multi-stage setup indicates the pruning step did not actually work as intended, often because the final stage's own install step was accidentally omitted, or because files were copied wholesale from an earlier stage that still included development dependencies rather than being freshly, separately installed.

Transitive dependency awareness

Pruning needs to account for transitive dependencies as well as direct ones, since a development dependency can pull in numerous additional packages that also have no place in production, and a naive review of only the top-level manifest's explicit categorization can miss a substantial amount of unnecessary, transitively-included content:

npm ls --all --omit=dev | wc -l
npm ls --all | wc -l

Comparing these two counts directly quantifies exactly how much of the full dependency tree is actually excluded by production-only installation, which is often considerably more than a quick glance at the top-level manifest's explicit dev versus production split would suggest.

Removing unused dependencies entirely, not just reclassifying them

Beyond correctly separating production from development dependencies, periodically reviewing whether every declared production dependency is actually still used by the application catches dependencies that were once needed but have since become dead weight, never moved to a dev-only category because no one revisited the decision after the code that used them was removed:

npx depcheck
pip-extra-reqs requirements.txt

Tools designed specifically to detect unused dependencies surface this category of cleanup opportunity directly, which a simple production-versus-development split alone would not catch, since an unused dependency might still be correctly categorized as a production dependency, just no longer actually needed by any code that remains in the application.

Language and build-tool specific approaches

Compiled languages have a somewhat different version of this same principle, where build-time dependencies (compilers, code generation tools) are excluded by virtue of the multi-stage build pattern entirely, while the final binary itself, statically or dynamically linked, carries only its genuine runtime dependencies:

FROM golang:1.22 AS build
RUN go mod download
RUN go build -o server .

FROM alpine:3
COPY --from=build /app/server /server

For compiled languages, the equivalent of dependency pruning is largely automatic, the compiler itself only links what the final binary actually requires, which means deliberate pruning effort is generally more relevant for interpreted or dynamically-loaded language ecosystems where unused dependencies remain present as files in the final image regardless of whether the application's own code path ever actually exercises them.

Common mistakes

  • Installing the full dependency set, including development tools, directly in the final production image rather than using a multi-stage build to perform a separate, production-only install.
  • Not auditing the actual final image's dependency tree directly, assuming pruning worked correctly based only on the Dockerfile's apparent structure.
  • Overlooking transitive dependencies pulled in by development tools, missing a substantial amount of unnecessary content that a top-level manifest review alone would not reveal.
  • Treating the production-versus-development split as the only relevant cleanup, without periodically checking for dependencies that are correctly categorized but simply no longer used by any remaining application code.
  • Assuming the same manual pruning effort relevant to interpreted languages applies equally to compiled languages, where the compiler itself already performs much of this exclusion automatically.

Dependency pruning practice complements base image minimalism by addressing the application's own dependency tree specifically, and doing it reliably requires structural enforcement through multi-stage builds, direct auditing of the actual final image's dependency tree, and periodic review for dependencies that remain correctly categorized as production but are no longer genuinely used by any code that still exists in the application.