Back to Blog
· 9 min read · EN

Docker Multi-Stage Builds: Smaller Images, Faster Deploys

A hands-on tutorial for Docker multi-stage builds covering Go, Node.js, and Java examples with security hardening, caching strategies, and CI/CD integration for production deployments.

DevOpsTutorial #docker#containers#multi-stage#optimization#ci-cd#production
Docker Multi-Stage Builds: Smaller Images, Faster Deploys

I review Docker images from client projects regularly. The pattern is always the same: a 1GB+ image containing the entire build toolchain, dev dependencies, source code, and test fixtures. All running as root. In production.

Multi-stage builds solve this problem completely. You build in one stage with all the tools you need, then copy only the final artifact to a minimal runtime image. The result: smaller images, faster deploys, better security.

This is one of the highest-leverage DevOps improvements you can make. 10 minutes of Dockerfile changes can reduce your image size by 90%+ and cut deployment time significantly.

Why Image Size Matters

Before the how, let me convince you of the why:

Pull time. ECS Fargate pulls your image on every new task launch. A 1.2GB image takes 30-45 seconds to pull. A 12MB image takes under 2 seconds. That is 30+ seconds added to every scale-out event and deployment.

Security surface. Every package in your image is a potential vulnerability. The golang:1.22 base image has 200+ packages, many with known CVEs. A distroless image has ~5 packages.

Storage cost. ECR charges for stored image bytes. 10 versions of a 1.2GB image: 12GB stored. 10 versions of 12MB: 120MB. Not a huge cost difference, but it adds up.

Build speed. Smaller images push faster to the registry. Your CI/CD pipeline completes sooner.

Image BaseTypical SizePackagesKnown CVEs
golang:1.221.2GB200+30-50
node:201.1GB400+50-80
eclipse-temurin:21450MB150+20-40
alpine:3.197MB150-2
distroless/static2MB50
scratch0MB00

Multi-Stage Build Concept

The core idea is simple: use one image for building, another for running.

Multi-Stage Docker Build

# Stage 1: BUILD (has everything needed to compile)
FROM golang:1.22 AS builder
# Install deps, compile code
RUN go build -o /app

# Stage 2: RUNTIME (has only what is needed to run)
FROM gcr.io/distroless/static
# Copy just the compiled binary
COPY --from=builder /app /app
ENTRYPOINT ["/app"]

The final image contains only the runtime stage. Build tools, source code, and intermediate artifacts are discarded. Docker keeps the builder stage cached locally for future builds but does not include it in the output.

Example 1: Go Binary from 1.2GB to 12MB

This is the Dockerfile I use for every Go service:

# ============ Build Stage ============
FROM golang:1.22-alpine AS builder

WORKDIR /src

# Cache dependencies (this layer rarely changes)
COPY go.mod go.sum ./
RUN go mod download && go mod verify

# Build the binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build \
    -ldflags="-w -s -X main.version=$(git describe --tags --always)" \
    -trimpath \
    -o /src/bin/api \
    ./cmd/api

# ============ Runtime Stage ============
FROM gcr.io/distroless/static-debian12:nonroot

# Copy binary and any required files
COPY --from=builder /src/bin/api /api
COPY --from=builder /src/migrations /migrations

EXPOSE 8080

USER nonroot:nonroot

ENTRYPOINT ["/api"]

Build flags explained:

  • CGO_ENABLED=0: Static binary, no C library dependency
  • -ldflags="-w -s": Strip debug info and symbol table (20-30% smaller binary)
  • -trimpath: Remove local file paths from binary (security)
  • GOARCH=amd64: Explicit target architecture

Runtime base explained:

  • distroless/static: Contains CA certificates and timezone data, nothing else
  • nonroot: Runs as non-root user (UID 65534)
  • No shell, no package manager, no way to exec into the container

Result: 1.2GB build image produces a 12MB runtime image.

Example 2: Node.js App from 1GB to 150MB

Node.js is trickier because you cannot compile to a single binary. You need the Node.js runtime and production dependencies:

# ============ Build Stage ============
FROM node:20-alpine AS builder

WORKDIR /app

# Install ALL dependencies (including devDependencies for building)
COPY package.json package-lock.json ./
RUN npm ci

# Copy source and build
COPY . .
RUN npm run build

# Remove devDependencies after build
RUN npm prune --production

# ============ Runtime Stage ============
FROM node:20-alpine AS runtime

# Security: create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy only production dependencies and build output
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

# Security hardening
USER appuser
ENV NODE_ENV=production

EXPOSE 3000

CMD ["node", "dist/main.js"]

Key optimizations:

  • npm ci instead of npm install: deterministic, faster, respects lockfile
  • npm prune --production: removes devDependencies after build
  • Only copy node_modules, dist, and package.json to runtime

Result: 1.1GB build image produces a ~150MB runtime image.

For even smaller, consider using node:20-alpine as runtime or exploring experimental Node.js single-executable applications.

Java apps are notorious for large images. Using jlink to create a custom JRE cuts the runtime size dramatically:

# ============ Build Stage ============
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app

# Cache Gradle/Maven dependencies
COPY build.gradle.kts settings.gradle.kts ./
COPY gradle ./gradle
RUN ./gradlew dependencies --no-daemon

# Build the application
COPY src ./src
RUN ./gradlew bootJar --no-daemon -x test

# Create custom JRE with only needed modules
RUN jlink \
    --add-modules java.base,java.logging,java.sql,java.naming,java.management,java.instrument,java.desktop,java.security.jgss,jdk.unsupported \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=2 \
    --output /custom-jre

# ============ Runtime Stage ============
FROM alpine:3.19

# Install minimal required packages
RUN apk add --no-cache tini

# Copy custom JRE
COPY --from=builder /custom-jre /opt/java

# Copy application
COPY --from=builder /app/build/libs/*.jar /app/app.jar

# Security
RUN addgroup -S app && adduser -S app -G app
USER app

ENV PATH="/opt/java/bin:${PATH}"
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

EXPOSE 8080

ENTRYPOINT ["tini", "--"]
CMD ["java", "-jar", "/app/app.jar"]

jlink magic: Instead of including the full 300MB JRE, jlink creates a custom runtime with only the modules your app uses. Typically reduces JRE from 300MB to 50-80MB.

Result: 450MB build image produces a ~120MB runtime image (vs 450MB+ without jlink).

Security Hardening

Beyond size reduction, multi-stage builds enable security best practices:

Non-Root User

Never run containers as root in production:

# Create user in the runtime stage
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

For distroless, use the built-in nonroot user:

FROM gcr.io/distroless/static:nonroot
USER nonroot:nonroot

Read-Only Filesystem

Prevent runtime modification of the filesystem:

# In ECS task definition or docker-compose
services:
  api:
    read_only: true
    tmpfs:
      - /tmp  # Allow temp files if needed

No Shell Access

Distroless images have no shell. Attackers who gain container access cannot execute commands:

# scratch and distroless have no shell
FROM gcr.io/distroless/static
# No /bin/sh, no /bin/bash, no way to exec into this container

Minimal Capabilities

Drop all Linux capabilities except what your app actually needs:

# docker-compose or ECS task definition
security_opt:
  - no-new-privileges:true
cap_drop:
  - ALL

Caching Strategies for Faster Builds

Layer Ordering

Docker caches layers top-down. Put rarely-changing layers first:

# GOOD: dependencies change less often than source code
COPY go.mod go.sum ./       # Layer 1: deps (cached unless go.mod changes)
RUN go mod download         # Layer 2: download (cached with Layer 1)
COPY . .                    # Layer 3: source (changes every commit)
RUN go build                # Layer 4: build (always rebuilds)
# BAD: copying source first invalidates dependency cache
COPY . .                    # Every code change invalidates ALL layers below
RUN go mod download
RUN go build

BuildKit Cache Mounts

BuildKit cache mounts persist build caches across builds without including them in the image:

# syntax=docker/dockerfile:1

FROM golang:1.22-alpine AS builder

WORKDIR /src
COPY go.mod go.sum ./

# Cache Go modules across builds
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .

# Cache build artifacts across builds
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o /src/bin/api ./cmd/api

For Node.js:

# syntax=docker/dockerfile:1

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./

# Cache npm packages across builds
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build

Cache mounts reduce build times by 50-80% for subsequent builds because downloaded dependencies persist.

Integration with CI/CD Pipelines

In your GitHub Actions pipeline, enable BuildKit and use caching:

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ${{ steps.login-ecr.outputs.registry }}/my-api:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

The type=gha cache uses GitHub Actions cache storage. Builds after the first one reuse cached layers, cutting build time significantly.

Debugging Multi-Stage Builds

When something goes wrong, you need to inspect intermediate stages:

Build a specific stage

# Build only the builder stage
docker build --target builder -t my-api:builder .

# Exec into it to investigate
docker run -it my-api:builder /bin/sh

Check what is in your final image

# See layer sizes
docker history my-api:latest

# Inspect filesystem
docker run --rm my-api:latest ls -la /

Use dive for layer analysis

# Install dive: https://github.com/wagoodman/dive
dive my-api:latest

Dive shows you exactly what each layer added and whether any files are wasted space.

Production Checklist

Before deploying a multi-stage Docker image to production:

  • Final image uses non-root user
  • No build tools in runtime image (no gcc, no make, no npm)
  • No source code in runtime image
  • No test files or fixtures in runtime image
  • CA certificates available (for HTTPS calls)
  • Timezone data available (if needed)
  • Health check endpoint works
  • Graceful shutdown handles SIGTERM
  • Image scanned for vulnerabilities
  • Image size under 200MB (ideally under 50MB for Go)
  • Build is reproducible (pinned base image tags)

Image Size Summary

LanguageBefore Multi-StageAfter Multi-StageReduction
Go1.2GB12MB99%
Node.js1.1GB150MB86%
Java (Spring Boot)450MB120MB73%
Python1.0GB200MB80%
Rust1.5GB8MB99%

The investment is minimal: 30 minutes to restructure your Dockerfile. The payoff compounds on every deploy, every scale event, and every security scan.

If you are running containers as part of your DevOps practice for a small team, optimized images are one of the highest-ROI improvements you can make.

Need help optimizing your container pipeline or setting up production Docker builds? Check out my DevOps support service where we audit and improve your build and deployment process.

See these build patterns used across production deployments in my backend and DevOps portfolio and Go gRPC service project.