Understanding Docker Multi stage build

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
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"]
Subscribe to my newsletter
Read articles from Rajesh Gurajala directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
