Understanding Docker Multi stage build

Rajesh GurajalaRajesh Gurajala
4 min read

Docker multi-stage builds let you break up a Dockerfile into multiple stages, each with its own base image and purpose. In a traditional single-stage build, all build tools and dependencies (compilers, linters, dev libraries, etc.) end up in the final image, resulting in bulky, insecure images.

Multi-stage builds solve this by splitting the Dockerfile into discrete phases: one (or more) build stages that compile or prepare the application, and a final runtime stage that only contains the minimal files needed to run it.

The build stages can use heavyweight base images (with compilers, SDKs, package managers) while the final stage uses a lightweight base (often a slim distro or a “distroless” image). This separation dramatically reduces the image size and attack surface

In fact, Docker’s best-practices guide explicitly recommends multi-stage builds to “reduce the size of your final image by creating a cleaner separation between the building of your image and the final output”.

ADVANTAGES:

  • Smaller images: By copying only the built artifacts (binaries, compiled code, static assets) from the build stage, the final image excludes compilers, source files, and dev dependencies.

  • Improved security: Fewer packages in the final image means fewer potential vulnerabilities. For example, the official Go image (golang:1.23) is ~800 MB with ~800 CVEs; multi-stage builds let you drop the Go toolchain from the final container. Also as they dont need to interact with host’s kernel, containers will be secure even though host might be having some vulnerabilities.

  • Faster and cleaner CI/CD: Build steps can cache separately (e.g. installing dependencies once), and intermediate stages let you run tests without polluting the final image. This speeds up rebuilds and ensures the runtime image is “production-ready” only.

SIMPLE EXAMPLE COMPARING WITH IT’S SINGLE STAGE BUILD :

Example 1: A Simple Python Flask App

Project Structure:

/flask-app
   ├── app.py
   ├── requirements.txt
   └── Dockerfile

app.py:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello():
    return "Hello, Docker!"

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

requirements.txt:

Flask

Dockerfile:


FROM python:3.9-slim
WORKDIR /app

COPY requirements.txt .
COPY app.py .

RUN python3 -m venv venv && \ 
    venv/bin/pip install --upgrade pip && \ 
    venv/bin/pip install --no-cache-dir -r requirements.txt

EXPOSE 5000

CMD ["venv/bin/python", "app.py"]

A MULTI STAGE BUILD FOR THE SAME PROJECT LOOKS LIKE:

FROM python:3.9-slim AS builder
WORKDIR /app

COPY requirements.txt .

RUN python3 -m venv venv && \
    venv/bin/pip install --upgrade pip && \
    venv/bin/pip install --no-cache-dir -r requirements.txt



# FINAL STAGE

FROM python:3.9-slim AS final
WORKDIR /app

COPY --from=builder /app/venv /app/venv
COPY app.py .

EXPOSE 5000
CMD ["venv/bin/python", "app.py"]

Example-2: A Golang based application

Project Structure:

/go-app
   ├── main.go
   └── Dockerfile

main.go:

package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"
)

func main() {
    fmt.Println("I am a calculator app ....")

    for {
        // Read input from the user
        reader := bufio.NewReader(os.Stdin)
        fmt.Print("Enter any calculation (Example: 1 + 2 (or) 2 * 5 -> Please maintain spaces as shown in example): ")
        text, _ := reader.ReadString('\n')

        // Trim the newline character from the input
        text = strings.TrimSpace(text)

        // Check if the user entered "exit" to quit the program
        if text == "exit" {
            break
        }

        // Split the input into two parts: the left operand and the right operand
        parts := strings.Split(text, " ")
        if len(parts) != 3 {
            fmt.Println("Invalid input. Try again.")
            continue
        }

        // Convert the operands to integers
        left, err := strconv.Atoi(parts[0])
        if err != nil {
            fmt.Println("Invalid input. Try again.")
            continue
        }
        right, err := strconv.Atoi(parts[2])
        if err != nil {
            fmt.Println("Invalid input. Try again.")
            continue
        }

        // Perform the calculation based on the operator
        var result int
        switch parts[1] {
        case "+":
            result = left + right
        case "-":
            result = left - right
        case "*":
            result = left * right
        case "/":
            result = left / right
        default:
            fmt.Println("Invalid operator. Try again.")
            continue
        }

        // Print the result
        fmt.Printf("Result: %d\n", result)
    }
}

Dockerfile:

FROM golang:1.18
WORKDIR /app

COPY main.go .

RUN go mod init myapp && \
    go build -o app .

EXPOSE 8080
CMD ["./app"]

A multi stage build docker file for this one :

FROM golang:1.18 AS builder
WORKDIR /app

COPY main.go .

RUN go mod init myapp && \
    go build -o app .


FROM scratch
WORKDIR /app

COPY --from=builder /app/app .

EXPOSE 8080
CMD ["./app"]
10
Subscribe to my newsletter

Read articles from Rajesh Gurajala directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Rajesh Gurajala
Rajesh Gurajala