DEV Community

Cover image for Docker Multi-Stage Builds: Slashing Image Size from 1.2GB to 45MB
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

Docker Multi-Stage Builds: Slashing Image Size from 1.2GB to 45MB

Docker Multi-Stage Builds: Slashing Image Size from 1.2GB to 45MB

Deploying a 1.5GB Docker container to production is painful. Large images slow down CI/CD pipelines, increase container registry storage costs, and prolong Kubernetes deployment rolling updates.

More importantly, bloated containers represent a massive security liability. If your production runtime image contains compilers (gcc, g++), package managers (npm, pip), and development header files, an attacker who gains remote code execution has all the tools necessary to download, compile, and execute malicious native exploits right on your host.

The solution is Docker Multi-Stage Builds. In this tutorial, we will take a real Node.js and Go application and slash the container footprint by over 95%.

How Multi-Stage Builds Work

Multi-stage builds allow you to declare multiple FROM instructions in a single Dockerfile. Each FROM begins a completely new stage with a clean base image.

Crucially, you can selectively copy only the compiled build artifacts from one stage to another, leaving behind the SDKs, compilers, and intermediate build tools.

+--------------------------------------------------------+
| Stage 1: Build & Compile (node:20-alpine)              |
|  - TypeScript Compiler                                 |
|  - devDependencies (ESLint, Prettier)                  |
|  - Generates: /dist/bundle.js (Artifact)               |
+--------------------------------------------------------+
                           |
            (COPY --from=builder /dist)
                           v
+--------------------------------------------------------+
| Stage 2: Production Runtime (node:20-alpine-minimal)   |
|  - Zero compilers, zero devDependencies                |
|  - Only production node_modules                        |
|  - Final Size: 58 MB!                                  |
+--------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Production Node.js Multi-Stage Dockerfile

# STAGE 1: Install Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# STAGE 2: Build & Compile TypeScript
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
RUN npm prune --production

# STAGE 3: Production Runtime
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
USER node

COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node package.json ./

EXPOSE 3000
CMD ["node", "dist/main.js"]
Enter fullscreen mode Exit fullscreen mode

Extreme Optimization: Go and Rust with Distroless / Scratch

For compiled languages like Go, Rust, or C++, you do not even need an operating system or shell at runtime!

You can compile a statically linked binary and deploy it into Google's Distroless image or an empty scratch image.

# Stage 1: Compile Statically Linked Go Binary
FROM golang:1.22-alpine AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/api-server .

# Stage 2: Pure Scratch Runtime
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app

COPY --from=builder /bin/api-server /app/api-server

USER nonroot:nonroot
ENTRYPOINT ["/app/api-server"]
Enter fullscreen mode Exit fullscreen mode

The Benchmark: Size Comparison

Original Single-Stage Go Image:  ~1.15 GB
Multi-Stage with Alpine:         ~28.4 MB
Multi-Stage with Distroless:     ~14.2 MB  (98.7% Reduction!)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)