Modern web development demands fast, secure, and efficient deployment pipelines. One of the most powerful tools in the Docker ecosystem for optimizing builds is the multi-stage build. Especially in Node.js applications, multi-stage builds help developers craft lightweight, production-ready containers by separating build-time and runtime concerns.
A multi-stage build allows multiple FROM
statements within a single Dockerfile, each representing a different stage of the build process. Each stage can use its own base image, making it possible to install and use development dependencies temporarily and discard them before producing the final image. This reduces the overall image size, improves build times through better caching, and enhances security by excluding tools and libraries unnecessary for production.
Why Multi-Stage Builds Are Important for Node.js
Node.js applications often rely on build-time tools like TypeScript, Babel, or Webpack. During development, additional packages such as nodemon
or test runners are frequently included. While these tools are critical during development and build processes, they are not required at runtime. Including them in the production image leads to bloated containers and increased security surface areas. Multi-stage builds solve this problem by allowing developers to compile and bundle in one stage, and copy only the essential artifacts—such as the dist/
directory and node_modules
with production dependencies—into a clean, minimal image for deployment.
Structure of a Multi-Stage Dockerfile
A typical Node.js multi-stage Dockerfile is divided into two main stages:
1. Build Stage – This installs all dependencies (including development tools), compiles source code, and builds the application.
2. Production Stage – This copies only the necessary runtime files from the build stage to create a minimal, efficient production image
Example: Multi-Stage Dockerfile
Below is a simplified example of a multi-stage Dockerfile for a Node.js application:
# ========== STAGE 1: Build ==========
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ========== STAGE 2: Production ==========
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
This structure installs dependencies and compiles code in the first stage. The second stage builds a lean container by copying only the output and essential runtime files.
Key Optimizations
There are several benefits to using this approach:
1. Smaller Final Image: Using a lightweight base image such as node:20-alpine
and copying only production dependencies dramatically reduces image size.
2. Faster Builds: Docker layer caching is optimized by copying package.json
first and installing dependencies before copying the rest of the app.
3. Improved Security: Development tools and source code are excluded from the final image, reducing potential attack vectors.
Advanced Multi-Stage Build with Pruning
For even better optimization, developers can prune development dependencies after the build step. Here’s an example:
# ========== STAGE 1: Install & Build ==========
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
COPY tsconfig.json ./
COPY src ./src
RUN npm install
RUN npm run build
RUN npm prune --production
# ========== STAGE 2: Production ==========
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
This variation first builds the application using full dependencies, then removes devDependencies
via npm prune --production
, ensuring that only production code and packages are passed to the final image.
When to Use Multi-Stage Builds
Multi-stage builds are particularly useful in the following scenarios:
1. Frontend Apps (React, Vue, Angular): Use npm run build
and copy the resulting build/
directory to a lightweight Nginx image.
2. Backend Services (Express, NestJS): Compile TypeScript code and transfer only the dist/
directory to a Node.js image.
3. Monorepos: Use separate stages for each service, enabling modular and reusable build logic.
Best Practices
To ensure efficiency and maintainability in multi-stage Docker builds:
Use .dockerignore
to exclude unnecessary files like node_modules
, .git
, .env
, and the Dockerfile
itself.
Use npm ci
instead of npm install
for consistent builds in CI/CD pipelines.
Minimize Layers: Combine related commands in a single RUN
statement: dockerfileCopyEditRUN npm install && npm run build && npm prune --production
Use --from=stage-name
to clearly reference previous stages for file copying, improving readability.
Final Notes
Multi-stage Docker builds can drastically reduce final image sizes—sometimes from 1GB down to 150MB. They not only improve performance by speeding up deployment and minimizing pull times, but also enhance security by ensuring only the essential code and dependencies are shipped to production.