← Back
April 4, 2026Docker7 min read
Docker multi-stage builds cut my image size from 1.2GB to 180MB
Published April 4, 20267 min read
My Node.js Docker image was 1.2GB because it included the full node_modules (with dev dependencies), TypeScript compiler, and build tools. I implemented multi-stage builds and the production image dropped to 180MB — just the runtime, compiled code, and production dependencies. Deploy times halved. Here is the pattern for Node.js, Python, and Go.
The problem with single-stage builds
dockerfile
# Bad: everything ends up in the final image
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install # Includes devDependencies: typescript, @types/*, jest...
COPY . .
RUN npm run build # TypeScript compiler still in image
CMD ["node", "dist/index.js"]
# Image size: ~1.2GB
Multi-stage Node.js
dockerfile
# Stage 1: Builder — has dev tools, produces compiled output
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # Install ALL dependencies (including dev)
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build # Compile TypeScript -> dist/
# Stage 2: Production — only runtime needed
FROM node:20-alpine AS production
WORKDIR /app
# Only copy package files and install PRODUCTION deps
COPY package*.json ./
RUN npm ci --omit=dev # No devDependencies
# Copy ONLY the compiled output from builder stage
COPY --from=builder /app/dist ./dist
# Run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
# Image size: ~180MB
Multi-stage Python/FastAPI
dockerfile
# Stage 1: Build — install all dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --upgrade pip
COPY requirements.txt requirements-dev.txt ./
# Install to a custom location for easy copying
RUN pip install --prefix=/install -r requirements.txt
# Stage 2: Production
FROM python:3.12-slim AS production
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy only application code
COPY src/ ./src/
# Non-root user
RUN useradd --no-create-home --shell /bin/false appuser
USER appuser
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Multi-stage Go (smallest possible image)
dockerfile
# Go compiles to a single static binary — perfect for tiny images
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o server ./cmd/server
# Stage 2: Scratch image (literally nothing but your binary)
FROM scratch AS production
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]
# Image size: ~12MB
Caching dependencies efficiently
dockerfile
# Key principle: copy dependency files BEFORE source code
# Docker caches layers — if package.json hasn't changed, npm install is cached
# BAD: copies everything before install — every code change invalidates npm cache
FROM node:20-alpine
COPY . .
RUN npm install # Runs every time any file changes
# GOOD: only package.json changes trigger reinstall
FROM node:20-alpine
COPY package*.json ./ # Only these two files
RUN npm ci # Only runs when package*.json changes
COPY . . # Source changes don't affect node_modules cache
RUN npm run build
The image size comparison
Node.js single-stage: 1.2GB Node.js multi-stage: 180MB Python single-stage: 800MB Python multi-stage: 280MB Go single-stage: ~800MB (with Go toolchain) Go scratch: ~12MB (static binary)
Beyond the size reduction, multi-stage builds improve security: your build tools (TypeScript, gcc, make) are not in the runtime image and cannot be exploited if the container is compromised. The pattern adds maybe 5 lines to your Dockerfile and immediately pays off in faster deploys, faster container starts, and less ECR/Docker Hub storage cost.
Share this
← All Posts7 min read