Building Better Containers: Docker Multi-Stage Builds

There are several factors why multi-stage builds can help build better containers.

  1. Reduced image size means less disk space is used. This can also speed up builds, deployments, and container startup.
  2. 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.
  3. 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

Go
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.

Dockerfile
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.

Dockerfile
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.

Bash
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.

Dockerfile
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.

Bash
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.

Dockerfile
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.

Bash
docker build -t rinavillaruz/single-php --no-cache -f Dockerfile-single-php .

Check the image size.

Let’s try reducing.

Dockerfile
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.

Bash
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

Leave a Reply