
There are several factors why multi-stage builds can help build better containers.
- Reduced image size means less disk space is used. This can also speed up builds, deployments, and container startup.
- Separating build time and run time means keeping the tools and libraries separate from the runtime environment, which can achieve a cleaner and more maintainable setup.
- Minimize attacks. By having only the needed libraries in the runtime, attackers will have difficulty exploiting unnecessary scripts.
Let’s use Golang as our first example. We will first create a single-stage build and compare the size to the multi-stage build.
main.go file
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World!\n")
}
func main() {
http.HandleFunc("/", handler)
// Start the server on port 80
err := http.ListenAndServe(":80", nil)
if err != nil {
fmt.Println("Error starting server:", err)
}
}Create a single-stage build Dockerfile named Dockerfile-single-golang.
FROM ubuntu:latest
WORKDIR /app
RUN apt update \
&& apt install -y golang
COPY /hello-world-go/main.go .
RUN go mod init hello-world-go && go build -o hello-world-go
EXPOSE 80
CMD ["./hello-world-go"]Let’s use Ubuntu as the base image, install go lang, and build the executable file. How did I come up with this? At first, I ran the Dockerfile with only FROM ubuntu:latest as its content. I go inside the container and install golang.
FROM ubuntu:latest
CMD [“bash”]I get the history of all my commands using the command history. From the history, I managed to get all commands and put them together to create a Dockerfile.
Build the Dockerfile.
docker build -t rinavillaruz/single-golang -f Dockerfile-single-golang .
Check the docker image.

The terminal shows the size of the single-golang image is 680MB which is huge. We can do better.
Revise the Dockerfile and make it a multi-stage build. Name it as Dockerfile-multi-golang.
FROM ubuntu:latest AS build
WORKDIR /app
RUN apt update \
&& apt install -y golang
COPY /hello-world-go/main.go .
RUN go mod init hello-world-go \
&& go mod tidy \
&& CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o hello-world-go \
&& chmod +x hello-world-go
FROM scratch AS final
WORKDIR /app
COPY --from=build /app/hello-world-go .
EXPOSE 80
CMD ["./hello-world-go"]We created a second stage or final stage. Scratch is an empty image but you can run a binary inside it.
It just basically copies the binary file from the build stage onto the final stage.
Let’s see the difference. Build the Dockerfile.
docker build -t rinavillaruz/multi-golang -f Dockerfile-multi-golang .
Check the image size.

The image is now reduced to 7MB. That’s a big difference. I have a demo of it at https://www.youtube.com/watch?v=GxW4-yEz_Mg.
In our example, we did a binary executable which is a standalone. How about PHP? Let’s try installing WordPress. You can get the instructions here https://wiki.alpinelinux.org/wiki/WordPress
Create a Dockerfile named Dockerfile-single-php. I used the base image php:8.4.3-fpm-alpine3.20 which is lightweight.
FROM php:8.4.3-fpm-alpine3.20
# Set working directory
WORKDIR /usr/share/webapps/
RUN apk add --no-cache \
bash \
lighttpd \
php82 \
fcgi \
php82-cgi \
wget
# Configure lighttpd to enable FastCGI
RUN sed -i 's|# include "mod_fastcgi.conf"|include "mod_fastcgi.conf"|' /etc/lighttpd/lighttpd.conf && \
sed -i 's|/usr/bin/php-cgi|/usr/bin/php-cgi82|' /etc/lighttpd/mod_fastcgi.conf
# Download and extract WordPress
RUN wget https://wordpress.org/latest.tar.gz && \
tar -xzvf latest.tar.gz && \
rm latest.tar.gz && \
chown -R lighttpd:lighttpd /usr/share/webapps/wordpress
EXPOSE 9000
CMD ["sh", "-c", "php-fpm & lighttpd -D -f /etc/lighttpd/lighttpd.conf"]Build the Dockerfile.
docker build -t rinavillaruz/single-php --no-cache -f Dockerfile-single-php .
Check the image size.

Let’s try reducing.
FROM php:8.4.3-fpm-alpine3.20 AS build
# Set working directory
WORKDIR /usr/share/webapps/
RUN apk add --no-cache \
bash \
lighttpd \
php82 \
fcgi \
php82-cgi \
wget
# Configure lighttpd to enable FastCGI
RUN sed -i 's|# include "mod_fastcgi.conf"|include "mod_fastcgi.conf"|' /etc/lighttpd/lighttpd.conf && \
sed -i 's|/usr/bin/php-cgi|/usr/bin/php-cgi82|' /etc/lighttpd/mod_fastcgi.conf
# Download and extract WordPress
RUN wget https://wordpress.org/latest.tar.gz && \
tar -xzvf latest.tar.gz && \
rm latest.tar.gz && \
chown -R lighttpd:lighttpd /usr/share/webapps/wordpress
FROM php:8.4.3-fpm-alpine3.20 AS final
WORKDIR /usr/share/webapps/
# Copy the compiled binary from the build stag
COPY --from=build /usr/share/webapps/ /usr/share/webapps/
# Install only the runtime dependencies
RUN apk add --no-cache \
lighttpd \
fcgi \
php82-cgi
EXPOSE 9000
CMD ["sh", "-c", "php-fpm & lighttpd -D -f /etc/lighttpd/lighttpd.conf"]In our example, since PHP doesn’t produce an executable binary, we don’t use the scratch image in the final stage. Instead, we reuse the php:8.4.3-fpm-alpine3.20 image and still need to install the required dependencies. As demonstrated, we only install the essential ones.
Let’s build the Dockerfile.
docker build -t rinavillaruz/multi-php --no-cache -f Dockerfile-multi-php .
Let’s check the image size.

The size reduction was minimal because PHP is an interpreted language. Unlike compiled languages, PHP cannot run independently—it relies on its runtime environment. PHP executes code line by line at runtime, essentially processing it “on the fly.” Additionally, installing dependencies like MySQL, PDO, or others increases the overall image size.
That’s it. Visit my YT Video for demo https://www.youtube.com/watch?v=GxW4-yEz_Mg
