Docker Full Tutorial — Part 8: Multi-Stage Builds

By Suraj Ahir 2025-11-29 11 min read

← Part 7Docker Tutorial · Part 8 of 12Part 9 →
Docker Full Tutorial — Part 8: Multi-Stage Builds

Here is a real scenario I dealt with at work. We had a React frontend being built and served with nginx. The naive Dockerfile installed Node.js, ran npm build, then started nginx to serve the built files. The image was 1.1GB. Deploying it meant pulling over a gigabyte of data. With multi-stage builds, the final image dropped to 28MB — because the production image only contains nginx and the compiled HTML/CSS/JS. Node.js is used to build but is not in the final image at all.

Single Stage vs Multi-Stage Comparison

BEFORE — Single stage (bloated)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install && npm run build
# All of node_modules, source code, and node itself is in this image
# Final size: ~1.1GB
CMD ["node", "serve.js"]
AFTER — Multi-stage (lean production image)
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production (only nginx + built files)
FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# Final size: ~28MB

Multi-Stage Build for Go Applications

Go app — from 800MB to 12MB
# Stage 1: Compile
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server

# Stage 2: Minimal runtime
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
# FROM scratch = completely empty image. Final size: ~12MB

Targeting Specific Stages

Build only a specific stage (for debugging)
# Build and stop at the builder stage (to debug build issues)
docker build --target builder -t myapp:debug .
docker run -it myapp:debug sh

# Build final production stage
docker build --target production -t myapp:prod .

In Part 9, we cover Docker security best practices — running containers as non-root, scanning images for vulnerabilities, using read-only filesystems, and secrets management.

Multi-Stage Builds for Python Applications

Python multi-stage — full example
# Stage 1: Build/compile dependencies
FROM python:3.11-slim AS builder

WORKDIR /build
COPY requirements.txt .

# Build wheels for all dependencies
RUN pip install --upgrade pip && \
    pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

# Stage 2: Production runtime
FROM python:3.11-slim AS production

WORKDIR /app

# Install only the pre-built wheels (no compiler needed)
COPY --from=builder /wheels /wheels
RUN pip install --no-cache --no-index --find-links=/wheels /wheels/* && \
    rm -rf /wheels

# Copy application code
COPY . .

# Security: non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser /app
USER appuser

EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4"]

BuildKit Advanced Features

BuildKit is Docker's next-generation build backend. It enables parallel stage building, better caching, and SSH forwarding for private dependencies.

Enable BuildKit and use advanced features
# Enable BuildKit
export DOCKER_BUILDKIT=1

# Mount SSH agent for private Git repos during build
docker build --ssh default .

# Mount secrets during build (not stored in image)
docker build --secret id=api_key,src=./api_key.txt .

# In Dockerfile — use the secret
RUN --mount=type=secret,id=api_key \
    API_KEY=$(cat /run/secrets/api_key) && \
    pip install --extra-index-url https://api:${API_KEY}@pypi.company.com/simple/ package-name

Measuring Build Performance

Analyse build performance
# Time a full build (no cache)
time docker build --no-cache -t myapp .

# Time a cached build
time docker build -t myapp .

# Export build timeline (requires BuildKit)
docker buildx build --progress=plain -t myapp . 2>&1 | grep -E "CACHED|\[\d"

# See what changed between two image versions
docker diff container-name

Tracking build times gives you visibility into regression. If a build that was taking 45 seconds starts taking 3 minutes, something changed in the dependency layer. Consistent measurement makes optimisation systematic rather than guesswork.

Frequently Asked Questions

What is a Docker multi-stage build?

A multi-stage build uses multiple FROM instructions in one Dockerfile. Each FROM starts a new stage with its own filesystem. You use early stages to compile code or install build tools, then copy only the compiled artifacts into a final minimal image. Build tools are discarded, leaving a small production image.

Why should I use multi-stage builds?

Without multi-stage builds, your production image contains compilers, build tools, test frameworks, and development dependencies that are not needed at runtime. These add hundreds of megabytes and increase the attack surface. Multi-stage builds keep only the runtime essentials in the final image — often reducing image size by 80-90%.

Can I name stages in a Docker multi-stage build?

Yes, use AS name after FROM: FROM node:20 AS builder. Then reference it in COPY: COPY --from=builder /app/dist ./dist. Naming stages makes the Dockerfile more readable and allows you to target a specific stage for debugging: docker build --target builder .

How much smaller do multi-stage builds make images?

Dramatically smaller. A typical Node.js application without multi-stage builds might produce a 1.2GB image including build tools. With multi-stage builds using an Alpine runtime, the same application produces a 120-180MB image. For Go applications, the difference is even more extreme — a Go binary can run in a 10MB distroless image.

What is a distroless image?

Distroless images (from Google) contain only the application and its runtime dependencies — no shell, no package manager, no standard Linux utilities. They are extremely small and have a very small attack surface. They are ideal for production Go, Java, and Python applications. The tradeoff is they are harder to debug since you cannot exec into them.

Key takeaways

Continue reading
Part 9 — Security Best Practices
How to not get hacked through your containers.
Suraj Ahir — author of SRJahir Tech

Written by

Suraj Ahir

Cloud & DevOps engineer running four live production services on my own AWS infrastructure. I write everything on this site myself — no ghostwriters, no AI filler.

← Part 7Docker Tutorial · Part 8 of 12Part 9 →
← Back to Blog
Disclaimer: This content is for educational purposes only. SRJahir Tech does not guarantee any specific outcome, job placement, or exam result.