Create a Multi-Stage Dockerfile for Smaller Images
Use multi-stage Docker builds to compile your application in one stage and copy only the runtime artifacts into the final small image. This pattern can reduce image sizes by 10x or more.
Task
Build a Node.js application with a multi-stage Dockerfile that produces a minimal production-ready image under 50MB.
When to use this
You have a Node.js backend using TypeScript. The Docker image built with the standard pattern is 1.2GB because it includes devDependencies, the TypeScript compiler, source maps, and build tooling. You want to reduce attack surface and deployment time.
Prerequisites
- Docker installed (Docker Engine 20.10+ or Docker Desktop 4.0+)
- Node.js project with a package.json and TypeScript config
- Basic familiarity with Dockerfile syntax
Steps
1. Create a Dockerfile with a build stage that compiles the TypeScript source. Use a Node image with the build toolchain.
# ---- Build Stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --include=dev
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
Expected output: Docker builds the image, installs all dependencies (including dev), and runs the TypeScript compiler to produce JavaScript output in /app/dist.
2. Create a production stage that copies only runtime dependencies and the compiled output. Use the Alpine or Distroless variant for the smallest footprint.
# ---- Production Stage ----
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Expected output: Docker creates the production image with only production dependencies (~30MB) and the compiled JS (the TypeScript compiler and source are NOT present).
3. Build the image and verify the size reduction.
docker build -t myapp:latest . && docker images myapp:latestExpected output: The final image should be 40-60MB compared to 1.2GB (build+dev+deps). Typical reduction: 20-30x for Node.js apps.
4. Verify that the application starts correctly.
docker run --rm -p 3000:3000 myapp:latestExpected output: Container starts, listens on port 3000, and responds to HTTP requests.
Troubleshooting
Problem
COPY --from=builder fails with file not found
Solution
Ensure the source path matches the BUILD output path. If your tsconfig outputs to ./dist, copy /app/dist not /app/build.
Problem
Container exits immediately with 'node: not found'
Solution
The production image may lack Node.js if you used Distroless. Use node:20-alpine or gcr.io/distroless/nodejs20-debian12 which includes the Node runtime.
Problem
npm ci fails with integrity check errors
Solution
Ensure package-lock.json is committed and matches package.json. Delete node_modules and lock file, regenerate with npm install.
Limitations
- Multi-stage builds do not help if your production dependencies themselves are large. Use OCI image squashing or third-party tools like docker-slim for further reduction.
- BuildKit (Docker Buildx) is required for some advanced multi-stage features like --mount=type=cache.
Why Multi-Stage Builds
A standard single-stage Dockerfile copies everything into one image: source code, build tools, devDependencies, cache files. The resulting image carries substantial attack surface and wastes bandwidth during deployment.
Multi-stage builds let you keep the build tooling in one stage and copy only the build artifacts into a clean, slim production stage.
How Multi-Stage Builds Work
Each FROM instruction starts a new stage. You can refer to earlier stages by name (AS builder) or index (--from=0). Only the last stage is included in the final image (unless you target a different stage with --target).
Advanced Patterns
Cache Mounts for Faster Builds
Use BuildKit cache mounts to persist the npm cache across builds:
RUN --mount=type=cache,target=/root/.npm \
npm ci --include=dev
Targeted Build for Development
FROM builder AS development
CMD ["npm", "run", "dev"]
# Usage: docker build --target development -t myapp:dev .
Security Scanning
After building, scan the minimal image for CVEs:
docker scout cves myapp:latest
A smaller image with fewer packages has fewer potential vulnerabilities.