Slim Production Images with Multi-Stage Docker Builds
How to build minimal production Docker images using multi-stage builds with distroless base images, covering Go, Node.js, Python, and Java examples with image size reduction.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Multi-stage builds let you use multiple FROM statements in a single Dockerfile. You compile and build in a full builder stage, then copy only the artifacts to a minimal final stage. Distroless images take this further — they contain only your application and its runtime dependencies, with no shell, package manager, or OS utilities. The result: images that are 10-100x smaller, have a minimal attack surface, and are production-ready.
When to Use
- Production deployments where image size matters (faster pulls, less storage)
- Security-sensitive environments (distroless has no shell for attackers)
- CI/CD pipelines where build tools shouldn’t be in the final image
- Any container that runs in Kubernetes or serverless platforms
- Compliance requirements that mandate minimal base images
When NOT to Use
- Development containers — you need a shell, package manager, and debugging tools
- When you need to exec into running containers for debugging — distroless has no shell
- When the image size doesn’t matter (internal tools, one-off scripts)
- When you need OS-level dependencies (glibc, specific libraries)
Solution
Basic multi-stage build (Go)
# Stage 1: Builder
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# Stage 2: Distroless final
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Multi-stage build for Node.js
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:22-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Multi-stage with distroless for Node.js
# Stage 1: Build
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Distroless
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER nonroot:nonroot
EXPOSE 3000
CMD ["dist/index.js"]
Multi-stage build for Python
# Stage 1: Builder
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .
RUN uv run build
# Stage 2: Production
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /app/.venv ./.venv
COPY --from=builder /app/dist ./dist
ENV PATH="/app/.venv/bin:$PATH"
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["python", "-m", "myapp"]
Multi-stage build for Java (Gradle)
# Stage 1: Build
FROM gradle:8-jdk21 AS builder
WORKDIR /app
COPY settings.gradle build.gradle ./
COPY gradle ./gradle
RUN gradle dependencies --no-daemon
COPY src ./src
RUN gradle bootJar --no-daemon
# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/build/libs/*.jar /app/app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Named stages for selective copying
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Test
FROM node:22-alpine AS tester
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run test
# Stage 4: Production
FROM node:22-alpine AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Using .dockerignore
# .dockerignore
node_modules
dist
.git
*.md
.env*
coverage
.vscode
.idea
Distroless with health checks
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
# Distroless has no shell — use binary health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/server", "--healthcheck"]
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
Build with BuildKit cache mounts
# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Variants
Distroless with CA certificates
FROM gcr.io/distroless/static-debian12:nonroot
# If your app needs CA certs for HTTPS
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["/server"]
Using Chainguard images
# Chainguard — minimal, no shell, daily patched
FROM cgr.dev/chainguard/go:latest AS builder
WORKDIR /app
COPY . .
RUN go build -o /server ./cmd/server
FROM cgr.dev/chainguard/static:latest
COPY --from=builder /server /server
USER nonroot
ENTRYPOINT ["/server"]
Alpine-based minimal image (alternative to distroless)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ./cmd/server
FROM alpine:3.20
RUN apk --no-cache add ca-certificates && \
adduser -D -h /app appuser
COPY --from=builder /server /app/server
USER appuser
WORKDIR /app
ENTRYPOINT ["/server"]
Debug distroless image
# Use :debug tag for debugging — includes busybox shell
FROM gcr.io/distroless/static-debian12:debug
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Best Practices
-
For a deeper guide, see Complete Guide to Docker in Production.
-
Use
CGO_ENABLED=0for Go — produces static binaries that work with distroless/static -
Strip binaries with
-ldflags="-s -w"— removes debug info, reduces size by ~30% -
Run as non-root user — add
USER nonroot:nonrootor create a custom user -
Use
.dockerignore— prevents copying node_modules, .git, etc. into the build context -
Pin base image versions — use
:1.22-alpinenot:latest -
Use BuildKit cache mounts — speeds up builds by caching dependencies
-
Copy dependencies before source code — takes advantage of Docker layer caching
-
Use
--platformfor multi-arch builds —docker buildx build --platform linux/amd64,linux/arm64
Common Mistakes
- Not using multi-stage: build tools (compilers, npm, go) end up in the final image, bloating it 10-100x.
- Using
:latesttags: base images change unexpectedly. Pin to specific versions. - Running as root: distroless supports
nonrootuser. Always specifyUSER nonroot:nonroot. - Copying entire build context: without
.dockerignore, Docker sends everything to the daemon. - Not stripping Go binaries:
-s -wflags remove debug symbols, saving ~30% of binary size. - Using distroless for development: no shell means no
docker exec -it ... sh. Use:debugtag for dev.
FAQ
What is a distroless image?
A Docker image that contains only your application and its runtime dependencies. No shell, no package manager, no OS utilities. Examples: gcr.io/distroless/static-debian12, gcr.io/distroless/nodejs22-debian12.
How much smaller are multi-stage images?
Typically 10-100x smaller. A Go app built with golang:1.22 is ~800MB. With multi-stage + distroless, it’s ~10-20MB. A Node.js app goes from ~1GB to ~150MB.
Can I debug a distroless container?
Use the :debug tag (e.g., gcr.io/distroless/static-debian12:debug). It includes a busybox shell. Alternatively, use docker cp to copy debugging tools into a running container.
What is the difference between distroless and Alpine?
Alpine is a minimal Linux distribution (~5MB) with a package manager (apk) and shell. Distroless has no shell or package manager. Alpine is good for general use; distroless is best for security-sensitive production.
How do I add CA certificates to distroless?
Use gcr.io/distroless/static-debian12:nonroot which includes CA certs. Or copy them from the builder: COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/.
Related Resources
Share Workflow Logic with GitHub Actions Reusable Workflows
How to create and consume reusable workflows in GitHub Actions, covering inputs, secrets, conditional jobs, matrix strategy, and organization-wide sharing.
RecipeOverride Docker Compose Configs per Environment
How to use Docker Compose override files for environment-specific configurations, covering dev, test, staging, production, profiles, and secrets management.
RecipePackage Kubernetes Manifests with Helm Charts
How to create, template, and deploy Kubernetes applications using Helm charts, covering values, conditionals, ranges, hooks, subcharts, and library charts.
RecipeSetup SSL Certificates with Let's Encrypt
How to obtain, install, and auto-renew SSL certificates using Certbot with Nginx, Apache, and standalone modes for HTTPS-enabled deployments.
RecipeAcross Multiple OS and Language Versions with GitHub
How to use GitHub Actions matrix strategy to test across multiple operating systems, language versions, and configurations with include, exclude, and dynamic matrices.