The Explosion
You declare three direct dependencies. Your build system downloads 487 packages. This is the transitive dependency explosion — a fundamental problem in modern software development.
The transitive dependency explosion operates as follows. When you declare express as a direct dependency, it brings body-parser, which itself depends on bytes, content-type, debug, and iconv-lite. The iconv-lite package depends on safer-buffer. Express also brings accepts, which depends on mime-types (which depends on mime-db) and negotiator. Express has 12 additional transitive dependencies beyond these. When you add pg as a second direct dependency, it brings buffer, crypto-js, packet-reader, and 11 more transitive dependencies. Your third direct dependency, lodash, is a leaf package with zero dependencies. In total, you declare 3 direct dependencies, but the build downloads 487 packages (3 direct plus 484 transitive), exposing your application to 847 CVEs across the entire dependency tree.
Each transitive dependency introduces security risk through vulnerabilities in code you don't explicitly need. This expands your attack surface as more packages mean more potential supply chain attack vectors. Size overhead increases as container images bloat with unused code. The update burden grows as more packages must be monitored and patched. License complexity escalates as more licenses must be tracked and comply with organizational policies. CleanStart's solution is to remove 85-95% of transitive dependencies through intelligent multi-stage builds.
How Multi-Stage Builds Work
Multi-stage builds separate the build environment from the runtime environment by using multiple FROM statements in a Dockerfile, where the first stage contains all build tools and dependencies, and the final stage contains only what the application needs at runtime.
# Stage 1: Builder (has everything needed for compilation)FROM node:18-alpine AS builder WORKDIR /build # Copy package filesCOPY package*.json ./ # Install ALL dependencies (including dev, build tools, optional)RUN npm ci --include=dev # Copy source and buildCOPY src/ ./src/RUN npm run buildRUN npm run test # Produce compiled artifact (dist/, artifacts/)RUN npm run dist # Stage 2: Runtime (only what's needed to run the app)FROM node:18-alpine AS runtime WORKDIR /app # Copy ONLY production dependenciesCOPY --from=builder /build/dist/ ./dist/COPY --from=builder /build/node_modules_prod/ ./node_modules/ # Copy package.json for runtime reference onlyCOPY --from=builder /build/package.json ./ # Don't install anything - deps already available CMD ["node", "dist/server.js"]Result: Builder stage: 487 packages, 1.2 GB, Runtime stage: 52 packages, 187 MB, and Reduction: 89.3% (435 packages removed).
Why This Works
Development Dependencies Are Never Shipped
{ "dependencies": { "express": "4.18.2", "pg": "8.9.0", "lodash": "4.17.21" }, "devDependencies": { "jest": "29.5.0", "typescript": "5.0.0", "@types/node": "18.15.0", "eslint": "8.40.0", "prettier": "2.8.8", "nodemon": "2.0.20" }}In the builder stage, all dependencies (including jest, eslint, prettier) are installed. These are used for testing, linting, and development. In the runtime stage, only the production dependencies (express, pg, lodash) are copied. The test frameworks, linters, and dev tools are left behind in the builder stage's filesystem.
Build Tools Don't Ship
Languages like Go and Rust illustrate this dramatically:
# Go multi-stage buildFROM golang:1.20 AS builderCOPY . /srcRUN cd /src && go build -o app FROM scratch # Minimal base imageCOPY --from=builder /src/app /appCMD ["/app"]A compiled Go binary has no runtime dependencies. The Go compiler, linker, standard library compilation tools — all left in the builder stage. The final image contains only the 15 MB compiled binary.
Optional Dependencies Are Conditional
Some packages are installed only if certain conditions are met:
// In package.json{ "optionalDependencies": { "bcrypt": "5.1.0", // Native module, optional "pgsql-native": "2.6.0" // Optional production dependency }}At runtime, if these optional dependencies aren't required for functionality, they can be excluded from the runtime image.
Language-Specific Examples
Node.js / npm
In the builder stage, run npm ci --include=dev to install everything. Compile TypeScript with npm run build. Run the test suite with npm run test. Lint code with npm run lint. Finally, remove dev dependencies with npm prune --production.
In the runtime stage, copy only node_modules/ and source code (already compiled).
This typically achieves a reduction of ~70-80% of packages removed.
Python / pip
In the builder stage, install all requirements. Install dev tools like pytest, black, mypy. Copy source code. Run tests. Build artifacts.
In the runtime stage, copy only production requirements and built artifacts.
This typically achieves a reduction of ~60-75% of packages removed.
Java / Maven
In the builder stage, download all dependencies. Run tests. Compile and package.
In the runtime stage, copy only the packaged JAR.
This typically achieves a reduction of ~80-90% of packages removed (test dependencies, Maven plugins).
Rust / Cargo
In the builder stage, compile with release mode. Run tests.
In the runtime stage, copy only the compiled binary.
This typically achieves a reduction of ~95%+ of dependencies removed (compiler, dev dependencies, test harnesses).
CleanStart's Multi-Stage Optimization
CleanStart extends standard multi-stage builds with additional optimization layers for dependency resolution, security scanning, and intelligent pruning.
Results: Stage 1 (deps): 487 packages. Stage 2 (builder): 542 packages (added dev tools). Stage 3 (security): Analyzed and generated SBOM. Stage 4 (pruned): 53 packages (removed unreachable code). Stage 5 (runtime): 52 packages. Final reduction: 89.3% (435 packages removed from runtime).
Vulnerability Impact
The dependency explosion means vulnerability reports explode too. When examining a raw CVE report covering all 487 dependencies, 847 vulnerabilities are discovered. However, this number is misleading because 234 CVEs exist only in development dependencies (removed by multi-stage builds), 156 CVEs exist only in build tools (also removed by multi-stage builds), 417 CVEs exist in unreachable code (removed by function analysis), leaving only 40 CVEs as genuine production risks. This represents a 95.3% reduction in actual vulnerability exposure.
By removing 435 unused packages through Library-Level Remediation — fixing vulnerabilities at the individual library level rather than at the image level — CleanStart achieves 807 associated CVEs removed, attack surface significantly reduced, image size from 1.2 GB to 187 MB, and eliminated monitoring burden for unused packages.
Best Practices for Multi-Stage Builds
1. Separate Clear Stages
# Stage 1: Dependency resolution (stable, cached well)FROM base AS deps # Stage 2: Build (changes frequently)FROM base AS builder # Stage 3: Tests (must pass)FROM builder AS tests # Stage 4: Runtime (minimal)FROM minimal AS runtime2. Freeze Lockfiles
Always use lockfiles in production builds: npm ci --frozen-lockfile, pip install -r requirements.lock.txt, cargo build --locked.
3. Clean Build Output
Remove build artifacts and caches with npm ci --production --no-save && npm cache clean --force && rm -rf /app/.npm /app/.cache /app/.git.
4. Use Alpine or Distroless Bases
Alpine images are ~5 MB. Distroless images are ~2 MB.
5. Verify Layer Sizes
docker build -t myapp .docker history myapp # Output shows size of each layer# Identify bloated stages for optimizationThe Dependency Pruning Opportunity
Not all transitive dependencies are equal. Some are used extensively; others are imported but never called.
CleanStart's cleanstart-prune analyzes function reachability:
cleanstart-prune \ --entry-point dist/server.js \ --sbom sbom.json \ --aggressive-pruning \ --output node_modules_optimized/ # Output:# Original package count: 487# Packages with reachable code: 52# Dead code packages: 435# Size reduction: 89.3%# Function coverage: 34% (of all exported functions)Gotchas and Caveats
What multi-stage optimization doesn't catch includes dynamic imports, plugin systems, reflection, optional dependencies, and transitive production dependencies. As a rule of thumb, multi-stage builds remove 85-95% of packages reliably. The remaining 5-15% require manual inspection or dynamic analysis.
Measuring Success
After implementing multi-stage builds:
Before Optimization: total_packages: 487 image_size: 1.2 GB reported_vulnerabilities: 847 build_time: 8m 45s deployment_time: 45s After Optimization: total_packages: 52 image_size: 187 MB reported_vulnerabilities: 40 build_time: 8m 20s deployment_time: 12s Improvements: package_reduction: 89.3% vulnerability_reduction: 95.3% image_size_reduction: 84.4% deployment_acceleration: 3.75x fasterMulti-stage builds are the foundation of CleanStart's container security posture: smaller attack surface, fewer vulnerabilities, faster deployments.
