Multi-stage Docker builds solve one of the most common Docker image size problems: development tools, build compilers, test frameworks, and package managers being included in production images. Without multi-stage builds, a Node.js production image might include the full npm package tree, TypeScript compiler, and development dependencies — increasing image size from ~50 MB to 500+ MB and expanding the attack surface with unnecessary tools. Multi-stage builds use multiple FROM instructions in a single Dockerfile, where each stage can copy specific files from previous stages. Only the final stage becomes the production image, so build tools are discarded automatically. This guide covers multi-stage build patterns for Node.js, Java (Maven), Go, and Python applications on RHEL 9.

Prerequisites

  • Docker Engine installed on RHEL 9

Step 1 — Node.js Multi-Stage Build

# Without multi-stage: node:20 image = 1.1 GB
# With multi-stage: node:20-alpine runtime = ~50 MB

# Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build  # TypeScript compile, webpack bundle, etc.

FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
docker build -t myapp:1.0.0 .
docker images myapp  # Compare size with single-stage build

Step 2 — Go Multi-Stage Build

# Go produces a single static binary — final image can be 'scratch' (zero OS)
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 -o /app/server ./cmd/server

# Final image: scratch (empty) or distroless — no shell, no OS utilities
FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=builder /app/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
# Result: ~10 MB image vs 300 MB+ single-stage

Step 3 — Java (Maven) Multi-Stage Build

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B  # Cache dependencies separately
COPY src ./src
RUN mvn package -DskipTests -B

FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
USER 1001
ENTRYPOINT ["java", "-jar", "app.jar"]
# Result: ~200 MB JRE image vs 800 MB Maven+JDK image

Step 4 — Build Specific Targets

# Build only up to a specific stage (useful for running tests in CI)
docker build --target builder -t myapp:test .
docker run --rm myapp:test npm test

# Use build arguments to pass version information
docker build 
    --build-arg APP_VERSION=1.2.3 
    --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) 
    -t myapp:1.2.3 .

Conclusion

Multi-stage Docker builds on RHEL 9 are the standard approach for producing minimal production images — a Node.js application built with multi-stage typically goes from 1+ GB to under 100 MB, and a Go application can be under 15 MB using a distroless final stage. The most impactful optimisation is separating dependency installation from the build step: copying only package.json and running npm install before copying source code means Docker can cache the dependency layer and skip reinstalling packages when only application code changes, dramatically reducing build times in CI/CD pipelines.

Next steps: How to Install Docker Engine on RHEL 9, How to Use Docker Secrets Securely on RHEL 9, and How to Set Up a Private Docker Registry on RHEL 9.