Docker Image Optimization with Multi-Stage Build

Have you ever noticed that the size of docker image reaches hundreds of MBs even for the small application, let’s say a simple Hello World program ? It’s because of the underlying base image which we often choose heavy and all the dependicies which are not even required for the application to run are included in the image. This bloated ultimately makes the application more resource intensive and makes the shipping of application slower. Docker images are meant to be lightweight, right, but sometimes we fail to achieve the main goal of containerization.

I have also faced this kind of problems multiple times until I found this technique of using “Multi-Stage Build” which significantly reduces the image size by hundreds of MBs.

I will show how multi-stage docker build helps in reducing the size of docker images and make the containers light-weight and less resource intensive. I will take three examples of application i.e.

i. Node Application ( React only)

ii. Java Application

iii. Go Application

Interpreted languages like Node.js, Python, and Ruby require a runtime environment for the application to run. But the compiled languages ( go, rust) don’t even require the runtime. Java application requires runtime because the processor can’t process the bytecode( generated by java compiler ) itself. We can simply create the executable and run in the minimal base image. These three different variations will clearly show how multi-stage build can be implemented in real life applications.

ReactJS Application

Let’s have a look at the single stage Dockerfile:

Single Stage Build

FROM node:20-alpine

WORKDIR /app

COPY package*.json .

RUN npm ci --omit=dev

COPY . .

CMD ["npm", "run", "start"]

EXPOSE 3000

For this Dockerfile, I’ll show you the image size after the build. Look at the size of image, it’s whooping 349 MB which is very large for a small react app.

Wait, I will show the same application multi-stage build.

Multi-Stage Build


FROM node:20-alpine AS build

WORKDIR /app

COPY . .

RUN npm ci --omit=dev &&  
npm run build

FROM nginx:1.27.4-alpine-slim

WORKDIR /usr/share/nginx/html

RUN rm -rf ./*

COPY --from=build /app/build .

COPY nginx.conf /etc/nginx/conf.d/default.conf

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

EXPOSE 80

For this Dockerfile, you will be amazed by the size of docker image. You will love the true power of multi-stage build. Here it is:

Isn’t it suprising ? From 349 MB to 13.5 MB !!!

This reduction in the image size is because Docker considers only the final stage (i.e. the last FROM instruction) to construct the image. So, we can take advantage of this by copying only the necessary dependicies and application code in the final stage and leaving rest of bloats in the first stage. It doesn’t even matter how large image you choose in the first stage because Docker won’t consider it anyways.

You can image how much this will affect the resource consumption and shipping of images. And, you might think that does the container even runs from this image ? Yeah, I have tested it. It runs without any issues.

But, I have faced some problems during configuration of nginx. I wasted hours in finding why the application is not running and finally, I figured out my silly mistake in this portion of package.json file.

You have to keep this homepage key empty while serving the static react build from nginx. But ,it took me an hour to find this. The satisfaction it provied after solving the issue was like eating the food after a week.

Java Application

Singe-stage build


WORKDIR /app

COPY . .

RUN ./mvnw clean package -DskipTests

CMD ["java", "-jar", "target/sample-java-app.jar"]

EXPOSE 8080

Multi-stage Build

# first stage ( build stage) 
FROM maven:3.9.9-eclipse-temurin-21-alpine AS build

WORKDIR /app

COPY pom.xml .

COPY ./src ./src

RUN mvn clean package -DskipTests

# second stage ( using the distroless image) 
FROM gcr.io/distroless/java21

WORKDIR /app

COPY --from=build /app/target/*.jar /app/app.jar

CMD ["/app/app.jar"]

EXPOSE 8080

Here’s the image size for both builds:

You can see the difference in image size in both builds.

In this Java Multi-Stage Build, I have used the distroless image in the final stage and copied only the jar file (artifact) to the final image making the image more lighter.

Distroless image is the minimal base image that includes only the necessary OS files, libraries without any package managers and even the shell. On the top of it, we include our application code and runtime dependicies. These images are the ones that are used in production environment because they are more secure and have minimal vulnerablities. Attacks like command injection aren’t seen in this type of images.

But, everything no matter how good it is has some issues right, and, this distroless images also carries such issues. Because of the minimal OS tools and missing shell, it’s very challenging to debug the containers once created. We don’t even have the command like ls, cat , etc. So, we have to use other images if we need troubleshooting and use this distroless image at the final stage. Here’s the link you can checkout for the Docker distroless images offered by Google.

https://github.com/GoogleContainerTools/distroless.git

Additional Tip: Don’t forget to check the documentation of distroless images for different runtime environments which you can find in the above link. Initially, I didn’t check the docs and faced the error because the entrypoint of the distroless image for java was already defined. We have to pass only the path of .jar file and I was passing the entire command using CMD in the Dockerfile. Later, I fixed it after so many tries of building the image.

Go Application

Let’s take the full benefit of multi-stage build in statically compiled language like Go.

The Go executable doesn’t need anything ( OS related components), so we can use special image called Scratch(image without OS) which is the smallest Docker image. Scratch is an empty image, meaning it contains nothing. Applications compiled with static linking, like Go binaries, can run directly on it because they don't need shared libraries from an OS. The scratch container is like a process which exits after running the executable. No need to say that, this is the most secure image. Hehehe !

Let’s have a look at the size of Go image based on scratch.

Singe-Stage Build

# single stage build

FROM golang:1.23-alpine3.21

WORKDIR /app

COPY ./hello/ .

RUN go mod tidy && \
    go build -o myapp .

ENTRYPOINT [ "./myapp" ]

Multi-Stage Build

# build stage
FROM golang:1.23-alpine3.21  AS build

WORKDIR /app

COPY ./hello/ .

RUN go mod tidy && \
    go build -o myapp .


# final stage
FROM scratch

WORKDIR /app

COPY --from=build /app/myapp ./myapp

ENTRYPOINT [ "./myapp" ]

Checkout the image size for both builds:

Can you see the difference ? 280 MB to 2.36 MB

Although it ‘s a simple Hello World Go application. But, this difference in the image size shows the true power of multi-stage build. I have only copied the go build ( executable) from the first stage and the application still runs and works perfectly fine.

By implementing multi-stage builds, you can significantly reduce your Docker image size, improve security, and optimize resource usage. Whether you're working with React, Java, or Go, this technique helps ship lightweight and efficient containers. Try applying this in your own projects and share your experience!

Key Points:

i. Always keep the frequently changing part at the bottom and constant part at the top of the Dockerfile for better caching of layers.

ii. In multi-stage build, it doesn’t matter how large the images are (except the final image).

iii. Use distroless image in the final stage if the application requires runtime ( like python, node, ruby, java ) and scratch image( or any other minimal images) for applications like Rust , Go that don’ t require any runtime.

iv. For debugging purposes, other alternative images (with shell) like Alpine should be used.

https://github.com/aditya-sridhar/simple-reactjs-app.git (react)

https://github.com/buildpacks/sample-java-app.git (java)

https://github.com/golang/example/tree/master/hello (golang)

Connect with me on:

🔗 LinkedIn: linkedin.com/in/anjal-poudel-8053a62b8