How to Build Docker Images with Multi-Stage Builds on RHEL 7
Docker multi-stage builds are one of the most powerful techniques for reducing the size of production container images. Before multi-stage builds were introduced, developers often maintained separate Dockerfiles — one for development (with compilers, build tools, and test frameworks) and a leaner one for production. Multi-stage builds solve this problem elegantly by allowing multiple FROM instructions in a single Dockerfile, where each instruction starts a new build stage. Intermediate stages containing build dependencies are discarded, and only the artifacts you explicitly copy into the final image are included. This tutorial walks you through setting up and using multi-stage builds on Red Hat Enterprise Linux 7 (RHEL 7), with a practical Node.js application example.
Prerequisites
- RHEL 7 with a valid subscription or CentOS 7 equivalent
- Docker CE installed and running (
systemctl status docker) - Basic familiarity with Dockerfiles and
docker build - A working internet connection or local registry to pull base images
- At least 2 GB of free disk space under
/var/lib/docker
If Docker is not yet installed, install it from the Docker CE repository:
sudo yum install -y yum-utils device-mapper-persistent-data lvm2
sudo yum-config-manager --add-repo
https://download.docker.com/linux/centos/docker-ce.repo
sudo yum install -y docker-ce docker-ce-cli containerd.io
sudo systemctl enable docker
sudo systemctl start docker
Step 1: Understanding Multi-Stage Build Syntax
A multi-stage Dockerfile uses multiple FROM statements. Each FROM begins a new stage and can optionally be named with AS <name>. You can then reference earlier stages by name using the COPY --from=<name> directive. Stages that are not referenced in the final output are automatically discarded during the build.
Here is the fundamental structure:
# Stage 1: Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Final production image
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
In this example, the builder stage installs dependencies. The final stage copies only the compiled node_modules and application code, without any build tooling overhead.
Step 2: Creating a Sample Node.js Application
Create a project directory and a minimal Express application to demonstrate the build process:
mkdir -p ~/docker-multistage-demo
cd ~/docker-multistage-demo
cat > package.json <<'EOF'
{
"name": "demo-app",
"version": "1.0.0",
"main": "server.js",
"dependencies": {
"express": "^4.18.2"
}
}
EOF
cat > server.js <<'EOF'
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({ message: 'Hello from RHEL 7 multi-stage build!', status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
EOF
Step 3: Writing the Multi-Stage Dockerfile
Now create the Dockerfile with two distinct stages. The first stage installs all dependencies including development tools. The second stage uses a minimal base and copies only what is needed to run the application:
cat > Dockerfile <<'EOF'
# ─── Stage 1: Dependency installation ───────────────────────────────────────
FROM node:18-alpine AS deps
WORKDIR /build
COPY package*.json ./
# Install production deps only; build tools stay in this stage
RUN npm ci --only=production
# ─── Stage 2: Production image ───────────────────────────────────────────────
FROM node:18-alpine AS production
# Set non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy only production node_modules from deps stage
COPY --from=deps /build/node_modules ./node_modules
# Copy application source
COPY server.js .
COPY package.json .
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
EOF
Step 4: Creating a .dockerignore File
A .dockerignore file prevents unnecessary files from being sent to the Docker build context, which speeds up builds and reduces accidental inclusion of secrets or large files:
cat > .dockerignore <<'EOF'
node_modules
npm-debug.log
.env
.env.*
*.md
.git
.gitignore
tests/
coverage/
EOF
Without .dockerignore, Docker sends the entire build context including node_modules to the daemon before the build starts, which is slow and wasteful when using multi-stage builds.
Step 5: Building the Image
Build the final production image using docker build. The -t flag assigns a name and tag:
docker build -t demo-app:multistage .
To build only a specific stage — useful for debugging an intermediate stage — use the --target flag:
# Build only the deps stage
docker build --target deps -t demo-app:deps-only .
This is particularly useful during development when you want to inspect the build environment without building the final image.
Step 6: Comparing Image Sizes
To appreciate the impact of multi-stage builds, build a naive single-stage image and compare sizes:
cat > Dockerfile.naive <<'EOF'
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
EOF
docker build -f Dockerfile.naive -t demo-app:naive .
docker images | grep demo-app
You will typically see the naive image is several hundred megabytes larger than the multi-stage version, even for a trivial application. For compiled languages like Go, the savings are even more dramatic — the final image can contain only the statically compiled binary with no runtime dependencies at all:
# Go multi-stage example (reference only)
FROM golang:1.21-alpine AS gobuild
WORKDIR /src
COPY . .
RUN go build -o /app/server .
FROM scratch
COPY --from=gobuild /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Step 7: Running and Verifying the Container
Run the production image and verify it works correctly:
docker run -d --name demo-multistage -p 3000:3000 demo-app:multistage
docker ps
curl http://localhost:3000
You should see a JSON response. Inspect the running container to confirm the non-root user is being used:
docker exec demo-multistage whoami
docker exec demo-multistage id
Clean up the container when done:
docker stop demo-multistage
docker rm demo-multistage
Step 8: Using Build Arguments Across Stages
Build arguments defined with ARG are scoped to the stage in which they appear. To pass an argument to multiple stages, declare it in each stage where it is needed:
cat > Dockerfile.args <<'EOF'
ARG NODE_VERSION=18-alpine
FROM node:${NODE_VERSION} AS deps
ARG NODE_VERSION
WORKDIR /build
COPY package*.json ./
RUN npm ci --only=production
FROM node:${NODE_VERSION} AS production
COPY --from=deps /build/node_modules ./node_modules
COPY server.js .
EXPOSE 3000
CMD ["node", "server.js"]
EOF
# Build with a different Node version
docker build -f Dockerfile.args --build-arg NODE_VERSION=20-alpine
-t demo-app:node20 .
Note that ARG instructions before the first FROM are in a special global scope and can be referenced inside FROM itself, but they must be re-declared inside each stage if needed there.
Conclusion
Multi-stage Docker builds are an essential technique for any team running containers on RHEL 7 in production. By separating build tooling from runtime images, you dramatically reduce attack surface, image pull times, and disk usage on container hosts. The COPY --from directive gives you precise control over what enters the final image, and the --target flag makes it easy to debug intermediate stages without separate Dockerfiles. Combined with a well-crafted .dockerignore file and non-root user configuration, multi-stage builds are a cornerstone of container security and efficiency on Red Hat Enterprise Linux.