Reducing Docker Image Size by 95% with Multi-Stage Builds

Introduction

You’ve worked hard on your React app, and now it’s time to put it in a Docker container. You run docker init, build your image, and then push it to a registry. But then you see something that worries you: the image is more than 1GB! Your CI/CD pipeline is moving slowly, and the cost of storage is going up. Does this situation sound familiar?

This isn’t just a minor inconvenience; it’s a significant problem for modern development workflows. Fortunately, there is a simple and powerful solution: multi-stage builds.

This article will show you how to dramatically reduce the size of your Docker image using a hands-on example. We will build a simple React app in two ways: first, with a standard single-stage Dockerfile like the one docker init provides, and second, with a lean, production-ready multi-stage Dockerfile. The results will speak for themselves.

Why Smaller Docker Images are Better

Before we get into the “how,” let’s quickly talk about the “why.” A smaller Docker image doesn’t just save disk space; it also positively impacts the entire software development and deployment lifecycle:

  • Faster CI/CD pipelines: It is quicker to push to and pull from registries like Docker Hub, which speeds up deployments significantly.
  • Lower Storage Costs: Less space used in your container registry means lower cloud bills.
  • Better Security: The final image contains only the necessary compiled code and a web server. It doesn’t include your source code, the Node.js runtime, or build tools, which makes the attack surface much smaller.
  • Faster Scaling: If your app needs to scale out, it can start new container instances much faster by pulling a small image instead of a large one.

What are Multi-Stage Builds?

Think of the process as having a workshop and a showroom.

The Workshop (The Build Stage)

This is the first stage of your Dockerfile. It’s a temporary environment where you keep all your heavy tools and raw materials, such as the full Node.js image, npm, your devDependencies, and your source code. You use these tools to assemble, test, and compile your final product—the static HTML, CSS, and JS files.

The Showroom (The Final Stage)

This is the second and final stage. Here, you display the finished product in a clean, minimalist space. You don’t need the saws, hammers, and extra wood—just the final product. In Docker terms, you discard the “workshop” environment and copy only the built assets into a small, clean web server image like Nginx.

The Hands-On Demo – Dockerisation

This is our demo React project that increases a counter on each button click.

import React, { useState } from 'react'

export default function App() {
  const [count, setCount] = useState(0)
  return (
    <div className="container">
      <h1>Counter</h1>
      <p className="count">Count: {count}</p>
      <button className="btn" onClick={() => setCount(c => c + 1)}>Increase</button>
    </div>
  )
}

Step 1: The Single-Stage Build (The “Before” Picture)

Now, let’s create our first Dockerfile. This file will mimic the straightforward approach of copying everything into a Node.js image.

The docker init command is a newer feature primarily included with Docker Desktop. I’m running Docker Engine directly on Linux and don’t have this command available. I will create a Dockerfile manually instead.

# Dockerfile

# Use a base image with Node.js. 'alpine' is a smaller version.
FROM node:18-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy package.json and package-lock.json first to leverage Docker's cache
COPY package*.json ./

# Install all dependencies, including development ones
RUN npm install

# Copy the rest of your application's source code
COPY . .

# Build the application (this step is often missed in basic Dockerfiles,
# but even with it, the image remains large)
RUN npm run build

# The command to start the app (for development)
CMD ["npm", "start"]

# Expose the port the app runs on
EXPOSE 3000

Let’s build the image by running:

docker build -t react-app-single-stage .

The result: Building 55.7s (9/9) FINISHED. This gives us a build time of around 1 minute with a 9-layer image. The produced image size is 191 MB.

Step 2: The Multi-Stage Build (The “After” Picture)

Let’s do this the right way. Create a new file named Dockerfile.multistage.

A production-ready React application, after you run npm run build, is just a collection of static files (HTML, CSS, JavaScript). It no longer needs Node.js or npm to run. It simply needs a web server to deliver those files to a user’s browser.

This is where Nginx comes in.

  • Nginx is a Production-Grade Web Server: It is extremely fast, lightweight, and built specifically for serving static content and handling high-traffic websites.
  • The nginx:alpine Image is Tiny: The Docker image for Nginx is incredibly small (around 20-30MB) compared to the Node.js image.
# Dockerfile.multistage

# --- Stage 1: The "Builder" ---
# We use a full Node.js image to install dependencies and build our app
FROM node:18-alpine AS builder

# Set the working directory
WORKDIR /app

# Copy package.json and package-lock.json to leverage Docker cache
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy the rest of the application source code
COPY . .

# Build the React application for production
RUN npm run build

# --- Stage 2: The "Final" ---
# We use a lightweight Nginx server to serve our static files
FROM nginx:1.25-alpine

# Copy the built static files from the "builder" stage
# The path /app/build is where create-react-app puts the build artifacts
COPY --from=builder /app/build /usr/share/nginx/html

# Expose port 80 for the Nginx server
EXPOSE 80

# Nginx will automatically serve the index.html file from the copied directory
# This command starts the server in the foreground
CMD ["nginx", "-g", "daemon off;"]

The Moment of Truth

Let’s check our image sizes now.

docker images
REPOSITORY               TAG       IMAGE ID       CREATED         SIZE
react-app-single-stage   latest    5611a52031c4   3 minutes ago   191MB
react-app-multistage     latest    a38449cff168   6 minutes ago   48.4MB

Conclusion

By separating our build environment from our final production environment, we cut the size of our image by 75%. In realistic applications with JavaScript or Go, the compression ratio can reach up to 95%. The final image is small, secure, and contains only one thing: a highly optimized web server with our static React app.

Multi-stage builds aren’t just a neat trick; they are a fundamental best practice for creating production-ready Docker containers. They lead to faster deployments, improved security, and lower operational costs. Adopting this practice demonstrates a commitment to performance and efficiency—two qualities that all modern employers look for in a developer. So, the next time you build a Docker image, make it a multi-stage one.

Ali Alrahbe
Ali Alrahbe

Hi, 👋 I'm Ali Alrahbe, a cybersecurity professional passionate about building cloud infrastructures that are both secure and resilient.

I got my start in tech on the front lines of IT support. That experience didn't just teach me how to solve complex problems—it showed me that proactive security is the bedrock of any successful digital system. That realization drove me to specialize in cloud security.
I'm AWS Certified Solutions Architect Associate, I hold a Bachelor's degree in computer systems engineering and currently pursuing a Master's in Cybersecurity in Berlin, focusing on Cloud Security, DevSecOps, and Infrastructure as Code (IaC).

On my website, Corefortify.com, I document my journey, share hands-on projects, and break down complex security concepts in the evolving world of cloud technology.

Feel free to connect with me on LinkedIn!

Articles: 14