When I first started containerizing applications, I was obsessed with image size. Every kilobyte mattered. I’d strip away dependencies, remove documentation, and employ every trick I could find to make containers smaller. It wasn’t until a security audit that I realized I’d been thinking about this all wrong.
Smaller images aren’t really the goal. A lean, secure attack surface is.
Multistage Docker builds aren’t just an optimization technique — they’re a fundamental security practice that deserves a place in every DevOps engineer’s toolkit. Let me show you why.
The Problem With Single-Stage Builds
Consider a typical Node.js application using Express for a REST API:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm installCOPY . .
RUN npm run buildEXPOSE 3000
CMD ["node", "dist/index.js"]
On the surface, this looks reasonable. But here’s what’s actually happening:
Your final image contains the build-time dependencies (webpack, TypeScript compiler, Babel, testing frameworks), build artifacts (source maps, raw TypeScript files), and often the entire history of your application code. You’re shipping a complete development environment into production. That’s not just wasteful — it’s a security liability.
An attacker gaining access to your container doesn’t just get your compiled application. They get the build tools, source maps that reveal your code structure, and potentially sensitive strings from your development dependencies. The attack surface isn’t just your application; it’s everything used to build it.
Enter Multistage Builds
Multistage Docker builds solve this elegantly by allowing you to use one image for building and another for running:
# Stage 1: Builder
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm installCOPY . .
RUN npm run build# Stage 2: Runtime
FROM node:20-alpineWORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm install --only=productionEXPOSE 3000
CMD ["node", "dist/index.js"]
Now we’re talking. Your production image contains only what’s needed to run the application, nothing more. But let me push this further with something closer to what you should actually do:
# Stage 1: Dependencies
FROM node:20 AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
RUN npm cache clean --force# Stage 2: Builder
FROM node:20 AS builderWORKDIR /app
COPY package*.json ./
RUN npm ciCOPY . .
RUN npm run build# Stage 3: Runtime
FROM node:20-alpineWORKDIR /app# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001# Copy only what's needed
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./USER nodejsEXPOSE 3000
CMD ["node", "dist/index.js"]
Notice what I’ve added: a dedicated stage for production dependencies, explicit cache cleaning, and a non-root user. This isn’t just about image size anymore — this is about layered security.
Why This Matters for Security
1. Reduced Attack Surface
Every package, every tool, every binary in your container is a potential vulnerability. When you ship build tools to production, you’re not just adding bloat — you’re adding risk. npm comes with hundreds of transitive dependencies. Do you know what they all do? Neither do most attackers, which is exactly why they search for low-hanging fruit in build environments.
By including only production dependencies, you’ve eliminated an entire category of attack vectors.
2. Smaller Images = Fewer Vulnerabilities
This is where the size reduction becomes relevant. A smaller image naturally has fewer packages, which means fewer potential CVEs. When a vulnerability is discovered in a deeply nested dependency, you want your image to be small enough that you can quickly rebuild and redeploy. Large images mean longer build times, longer deployment times, and slower recovery from security incidents.
3. Supply Chain Security
Multistage builds make dependency scanning more effective. You can verify the security posture of your runtime image separately from your build image. Many organizations use base image scanning in their registries — smaller images fail fewer scans simply because there’s less to scan.
4. Immutability and Auditability
When your production image contains only runtime artifacts, you can cryptographically verify its contents. There’s no ambiguity about what should be there. This is critical for compliance frameworks like SOC 2 or HIPAA, where you need to prove that your production environment contains only approved, audited code.
A React + Express Example
Let me show you this with a real-world setup using React and Express:
# Stage 1: Frontend build
FROM node:20 AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ciCOPY frontend/ .
RUN npm run build# Stage 2: Backend dependencies
FROM node:20 AS backend-depsWORKDIR /backend
COPY backend/package*.json ./
RUN npm ci --only=production# Stage 3: Backend builder
FROM node:20 AS backend-builderWORKDIR /backend
COPY backend/package*.json ./
RUN npm ciCOPY backend/ .
RUN npm run build# Stage 4: Runtime
FROM node:20-alpineWORKDIR /app# Non-root user
RUN addgroup -g 1001 -S app && adduser -S app -u 1001# Copy compiled frontend
COPY --from=frontend-builder /frontend/dist ./public# Copy backend runtime deps and compiled code
COPY --from=backend-deps /backend/node_modules ./node_modules
COPY --from=backend-builder /backend/dist ./dist
COPY --from=backend-builder /backend/package*.json ./# Remove unnecessary files
RUN rm -rf /app/node_modules/.bin/*/test*USER appEXPOSE 3000
CMD ["node", "dist/server.js"]
The final image is lean, focused, and auditable. Your frontend assets are built separately and only the compiled, optimized output is included. Your backend contains no build tools, no source maps, no test files.
Beyond Size: The Security Checklist
When building multistage containers, ask yourself:
Is this necessary? Does this package, file, or directory need to exist in the runtime image?
Can this be built elsewhere? Should TypeScript compilation happen in a CI/CD stage before Docker runs?
What user runs this? Never run as root. Even lean images are problematic if they run with excessive privileges.
Is this reproducible? Can you audit what went into this image six months from now?
Can this be scanned? Tools like Trivy and Grype scan your images for vulnerabilities. Smaller, simpler images are easier to scan and understand.
The Real Win
Riddle this, a vulnerability in a production container where an attacker gains shell access. In a multistage build, they get nothing useful — no npm, no git, no utilities beyond what the Node.js runtime needs. In the old single-stage builds, they would’ve had a complete development environment.
That’s the real difference multistage builds make.
Size reduction is a pleasant side effect. The actual value is in building containers with the principle of least privilege from the ground up. Every dependency, every tool, every permission should be questioned. Multistage builds force you to think about this.
Start using them not because your images are too big, but because you want to sleep at night knowing your production environment is as lean and secure as possible.
Your future self — and your security team — will thank you.