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

Upgrading Docker from version 25 to version 27 in Amazon Linux 2023.6.20241212

I have this Jenkins server that I haven’t touched for a year, and when I started it, I was greeted with two upgrades. One is from Jenkins, and the other one is from AWS. I did an upgrade of AWS to 2023.6.20241212. Everything went fine until suddenly I checked my Docker, and I have an outdated version 25 which I believe is correct based on this information from AWS https://docs.aws.amazon.com/linux/al2023/release-notes/all-packages-AL2023.6.html

The latest version of Docker is now on version 27 based on the time of writing. I checked the details of the installed AWS Linux and it is based on Fedora https://aws.amazon.com/linux/amazon-linux-2023/faqs/

Let’s verify.

I did a google and found this command:

Bash
sudo dnf install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

I got an error:

Console Output
Docker CE Stable - x86_64                                         693  B/s | 417  B     00:00   
Errors during downloading metadata for repository 'docker-ce-stable':
 - Status code: 404 for https://download.docker.com/linux/fedora/2023.6.20241212/x86_64/stable/repodata/repomd.xml (IP: 0.0.0.0)
Error: Failed to download metadata for repo 'docker-ce-stable': Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried
Ignoring repositories: docker-ce-stable
Last metadata expiration check: 1:07:15 ago on Tue Jan  7 14:23:13 2025.
No match for argument: docker-ce
No match for argument: docker-ce-cli
No match for argument: containerd.io
No match for argument: docker-buildx-plugin
No match for argument: docker-compose-plugin
Error: Unable to find a match: docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

I followed this link just to see what it shows https://download.docker.com/linux/fedora/2023.6.20241212/x86_64/stable/repodata/repomd.xml

Looks like the package is unavailable.

So I decided to install it using Binaries or Manual Installation. You can check it here https://docs.docker.com/engine/install/binaries/

Or you can follow what i did (it’s just the same actually 😀):

Check the version you need at https://download.docker.com/linux/static/stable/

As of this writing the version is 27.4.1

Bash
// Download 
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.4.1.tgz -o docker.tgz

// Extract
tar xzvf docker.tgz

// Move the files
sudo mv docker/* /usr/bin/

// Starts the daemon, runs it in the background
sudo dockerd &

// Check docker version 
docker --version

// Check if it is correctly installed
Docker run hello-world

That’s it!

Launch WordPress Using Docker, Docker-Compose and ENV file

xr:d:DAF70A8lRQ8:14,j:8368105716694451060,t:24020408

I love using Docker-Compose because why not? Running Docker commands is painful.

Let’s compose the file:

docker-compose.yml
services:
  db:
    image: mysql:5.7
    container_name: local-db
    ports:
      - "3306:3306"
    volumes:
      - ./db/data:/var/lib/mysql
      - ./db/dump:/dump
    environment:
      MYSQL_ROOT_PASSWORD: $WORDPRESS_DB_PASSWORD
      MYSQL_DATABASE: $WORDPRESS_DB_NAME
      MYSQL_USER: $WORDPRESS_DB_USER
      MYSQL_PASSWORD: $WORDPRESS_DB_PASSWORD

  wordpress:
    container_name: local-wordpress
    volumes:
      - .:/var/www/html
    depends_on:
      - db
    image: wordpress:php7.4
    ports:
      - "80:80"
    environment:
      WORDPRESS_DB_HOST: $WORDPRESS_DB_HOST
      WORDPRESS_DB_USER: $WORDPRESS_DB_USER
      WORDPRESS_DB_PASSWORD: $WORDPRESS_DB_PASSWORD
      WORDPRESS_DB_NAME: $WORDPRESS_DB_NAME

As you can see in the file $WORDPRESS_DB_HOST, I replaced the actual value with the env variable.

Let’s now create the .env file. With my example below, I added random values.

.env
WORDPRESS_DB_HOST: 'db'
WORDPRESS_DB_USER: 'root'
WORDPRESS_DB_PASSWORD: 'root'
WORDPRESS_DB_NAME: 'wordpress'
WORDPRESS_TABLE_PREFIX: 'wp_'
WORDPRESS_AUTH_KEY: 'hi,Zm=$8!8Guue%PgH])'
WORDPRESS_SECURE_AUTH_KEY: '@wr3454545iP#xDIKou!'
WORDPRESS_LOGGED_IN_KEY: 'eHRAqn=gtOwn*ODe5rhx3ZH~c-'
WORDPRESS_NONCE_KEY: '[K*@QOTQ=_T{=]N'
WORDPRESS_AUTH_SALT: 'pogWdrwDZKWcGiq='
WORDPRESS_SECURE_AUTH_SALT: '~UbjyZ3$$)723T'
WORDPRESS_LOGGED_IN_SALT: '+d3rF1QxMQ!2'
WORDPRESS_NONCE_SALT: 'EX2'
WORDPRESS_WP_SITEURL: 'http://localhost'
WORDPRESS_WP_HOME: 'http://localhost'

Make sure the wp-config file should use the function getenv_docker

PHP
// ** Database settings - You can get this info from your web host ** //
/** The name of the database for WordPress */
define( 'DB_NAME', getenv_docker('WORDPRESS_DB_NAME', 'wordpress') );

/** Database username */
define( 'DB_USER', getenv_docker('WORDPRESS_DB_USER', 'example username') );

/** Database password */
define( 'DB_PASSWORD', getenv_docker('WORDPRESS_DB_PASSWORD', 'example password') );

To make it work,

Bash
docker-compose --env-file .env up -d

It should pull the images and run the containers.

View gist here: https://gist.github.com/rinavillaruz/c8c64d4aec689cdadbb3a92d5cb54837