docker

Complete Guide to Multi-Stage Docker Builds for Ultra-Lean Node.js, Go, and Python Containers

Shrink your Docker image sizes from 1.5GB to under 20MB. Master BuildKit cache mounts, security hardening with distroless images, and multi-architecture cross-compilation.

By Shubhankar Tripathi • • 14 min read

Introduction

Fat container images lead to slow CI/CD build times, high bandwidth costs, and most critically: broad vulnerability attack surfaces. A default node:20 or golang:1.22 image can easily consume between 1.2GB and 2.1GB on disk.

In this guide, you will learn how to leverage Multi-Stage Docker Builds, BuildKit cache mounts, and Distroless scratch images to compress your application binaries to under 25MB while drastically improving security posture.


Step 1 — Building an Ultra-Lean Go Binary (15MB)

Go compiles down to a single static binary, making it the premier candidate for multi-stage optimization.

Take a look at this production DockerFile:

## Stage 1: Build & Static Compilation
FROM golang:1.22-alpine AS builder
WORKDIR /src

# Leverage BuildKit cache mount for Go module dependencies
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download -x
COPY . .

# Compile static binary with optimizations
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o /bin/server ./cmd/api

## Stage 2: Distroless Minimal Runtime
FROM gcr.io/distroless/static-debian12:nonroot
USER nonroot:nonroot
COPY --from=builder --chown=nonroot:nonroot /bin/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

Compiler Flags Explained:
The -ldflags="-s -w" flag strips debugging symbol tables and DWARF debug info from the ELF binary, cutting binary size in half with zero performance loss.

Step 2 — Optimizing Node.js Container Builds

For interpreted runtimes like Node.js, we must separate build-time tools (TypeScript compiler, Webpack/Vite) from production runtime dependencies.

# Stage 1: Dependencies Resolution
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=optional

# Stage 2: Application Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --production

# Stage 3: Production Minimal Runner
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 appuser

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

USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"] 

Step 3 — Benchmarking Results

Let's compare the before and after sizes for both runtimes:

RuntimeStandard Single-Stage ImageMulti-Stage Distroless ImageSize Reduction
Go API1,180 MB (golang:1.22)14.2 MB (distroless/static)98.8%
Node.js API1,420 MB (node:20)128 MB (node:20-alpine stripped)91.0%
Python FastAPI1,020 MB (python:3.11)94 MB (python:3.11-slim wheels)90.8%


Summary
By separating compilation environments from deployment images, you achieve lightning fast deployments, eliminate unnecessary compilers from production servers, and establish a bulletproof baseline for container security.