Multi-stage builds are one of the most powerful features in Docker, allowing you to produce lean production images by separating the build environment from the runtime environment. On RHEL 8, where production image size and attack surface matter, multi-stage builds reduce final image size dramatically by discarding build tools and intermediate artifacts. This tutorial walks through building a Node.js application with a multi-stage Dockerfile, comparing image sizes before and after. By the end you will have a production-ready workflow you can adapt to any compiled or transpiled application.

Prerequisites

  • RHEL 8 system with Docker CE installed (or Podman with Docker compatibility)
  • Docker CE repository configured: dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
  • Docker daemon running: systemctl enable --now docker
  • Node.js project with a package.json and a build script (e.g., a React or Vite app)
  • Basic familiarity with Dockerfiles and the Docker CLI

Step 1 — Create a .dockerignore File

Before writing the Dockerfile, create a .dockerignore file in your project root to prevent copying unnecessary files into the build context. This speeds up builds and avoids leaking secrets.

cat > .dockerignore <<'EOF'
node_modules
npm-debug.log
dist
.git
.env
*.md
EOF

Step 2 — Write the Multi-Stage Dockerfile

Create a Dockerfile at the project root. The first stage uses node:20-alpine to install dependencies and run the build. The second stage uses the minimal nginx:alpine image and only copies the compiled output.

cat > Dockerfile <<'EOF'
# ── Stage 1: Build ──────────────────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app

# Copy manifests first for layer-cache efficiency
COPY package.json package-lock.json ./

# Install all dependencies (including devDependencies)
RUN npm ci

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

# ── Stage 2: Serve ──────────────────────────────────────────
FROM nginx:alpine

# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf

# Copy custom nginx config (optional)
# COPY nginx.conf /etc/nginx/conf.d/

# Copy compiled assets from the builder stage only
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]
EOF

Step 3 — Build the Image

Run the build from the project directory. Docker will execute both stages sequentially; only the second stage is committed to the final image.

docker build -t myapp:latest .

# Enable BuildKit for faster parallel builds (recommended)
DOCKER_BUILDKIT=1 docker build -t myapp:latest .

Step 4 — Compare Image Sizes

List images to verify that the multi-stage image is significantly smaller than a naive single-stage build that carries Node.js and all devDependencies into production.

# View final image size
docker image ls myapp

# Compare against a hypothetical single-stage build
docker image ls | grep -E 'REPOSITORY|myapp|node'

# Inspect layers to confirm no node_modules in final image
docker history myapp:latest

A typical React app built with a single stage using node:20 weighs around 1.2 GB. The multi-stage version using nginx:alpine as the final stage typically comes in under 30 MB — a reduction of over 97%.

Step 5 — Run and Verify the Container

Start the container and confirm the application is served correctly before pushing the image to a registry.

# Run in detached mode, map host port 8080 to container port 80
docker run -d --name myapp-test -p 8080:80 myapp:latest

# Verify the container is running
docker ps

# Test the response
curl -I http://localhost:8080

# Tail logs if needed
docker logs -f myapp-test

# Cleanup
docker rm -f myapp-test

Step 6 — Tag and Push to a Registry

Tag the image for your container registry and push it. The small image size means faster pushes and pulls across your CI/CD pipeline.

# Tag for Docker Hub
docker tag myapp:latest yourusername/myapp:1.0.0

# Or tag for a private registry
docker tag myapp:latest registry.example.com/myapp:1.0.0

# Push
docker push registry.example.com/myapp:1.0.0

# Verify the manifest
docker manifest inspect registry.example.com/myapp:1.0.0

Conclusion

Multi-stage builds let you use a full-featured build environment without paying the cost of shipping it to production. By separating the Node.js build stage from the final nginx:alpine serving stage, the resulting image is smaller, faster to transfer, and presents a far smaller attack surface — three properties that matter in any RHEL 8 production environment. The same pattern applies to Go, Java, Python, and virtually any compiled language: build heavy, ship light.

Next steps: How to Use Docker Secrets and Environment Variables Securely on RHEL 8, How to Configure Docker Daemon TLS Encryption on RHEL 8, and How to Install and Use Skopeo for Container Image Management on RHEL 8.