DEV Community

Oluwajubelo
Oluwajubelo

Posted on

Docker Multi-Stage Builds: Your Secret Weapon for Lean, Mean Container Machines

Picture this: You've just finished building your latest web application. It's beautiful, it works perfectly, and you're ready to containerize it. You write your Dockerfile, build the image, and... it's 2GB. For a simple Node.js app. Your deployment pipeline is crying, your servers are groaning, and your wallet is getting lighter with every cloud storage bill.
Sound familiar? Welcome to the world before multi-stage builds – where Docker images were bloated with build tools, source code, and dependencies that had no business being in production.

The Problem: When Docker Images Go on a Diet (But Refuse to Lose Weight)

Let's start with a real-world scenario. You're building a React application that needs to be compiled and served by an Nginx server. Here's what your Dockerfile might look like without multi-stage builds:

dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
RUN npm install -g serve
EXPOSE 3000
CMD ["serve", "-s", "build"]
Enter fullscreen mode Exit fullscreen mode

This approach has several problems:

  • Your final image includes Node.js, npm, and all development dependencies

  • The source code and intermediate build files are still there

  • The image size is unnecessarily large

  • You're potentially exposing security vulnerabilities through unused tools

It's like moving houses but taking all your old furniture, broken appliances, and that box of cables you'll "definitely use someday" – except in this case, you're paying for storage and bandwidth for all that digital clutter.

Enter Multi-Stage Builds: The Marie Kondo of Docker

Multi-stage builds allow you to use multiple FROM statements in your Dockerfile. Each FROM instruction starts a new build stage, and you can selectively copy artifacts from one stage to another, leaving behind everything you don't want in the final image.
Think of it as a relay race where each runner (stage) passes only what's necessary to the next runner, rather than carrying the entire team's equipment to the finish line.

Real-World Example 1: The React Application Transformation

Let's transform our bloated React app Dockerfile into a lean, multi-stage masterpiece:

dockerfile
# Stage 1: Build the application
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Serve the application
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

What just happened here?

  • Stage 1 (builder): We use Node.js to install dependencies and build our React app

  • Stage 2 (final): We use a lightweight Nginx image and copy only the built files

The results are dramatic:

  • Original image: ~1.2GB

  • Multi-stage image: ~25MB

  • That's a 98% reduction in size!

Real-World Example 2: Go Application - From Gigabytes to Megabytes

Go applications are perfect candidates for multi-stage builds because Go compiles to static binaries. Here's a typical Go web service:

dockerfile
# Stage 1: Build the Go binary
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .

# Stage 2: Create minimal runtime image
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]

Enter fullscreen mode Exit fullscreen mode

Even better - using scratch:

dockerfile
# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

# Stage 2: Ultra-minimal image
FROM scratch
COPY --from=builder /app/main /
EXPOSE 8080
ENTRYPOINT ["/main"]
Enter fullscreen mode Exit fullscreen mode

This creates an image that's literally just your binary – we're talking about images under 10MB for most Go applications!

Real-World Example 3: Python Flask Application with Poetry

Python applications often have complex dependency management. Here's how to handle a Flask app using Poetry:

dockerfile
# Stage 1: Build dependencies
FROM python:3.11-slim AS builder
RUN pip install poetry
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false \
    && poetry install --only=main --no-root
COPY . .
RUN poetry build

# Stage 2: Runtime image
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /app/dist/*.whl ./
RUN pip install *.whl && rm *.whl
COPY --from=builder /app/src ./src
EXPOSE 5000
CMD ["python", "-m", "flask", "run", "--host=0.0.0.0"]
Enter fullscreen mode Exit fullscreen mode

Advanced Multi-Stage Patterns

The Testing Stage Pattern

Want to run tests during your build but not include testing dependencies in your final image?

dockerfile
# Stage 1: Dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# Stage 2: Testing
FROM deps AS testing
COPY . .
RUN npm test

# Stage 3: Build
FROM deps AS builder
COPY . .
RUN npm run build

# Stage 4: Production
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
Enter fullscreen mode Exit fullscreen mode

You can then build with: docker build --target testing . to run tests, or without the target to get the production image.

The Development vs Production Pattern

dockerfile
# Base stage with common dependencies
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./

# Development stage
FROM base AS development
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

# Production build stage
FROM base AS builder
RUN npm ci --only=production
COPY . .
RUN npm run build

# Production runtime stage
FROM nginx:alpine AS production
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

Build for development: docker build --target development -t myapp:dev .
Build for production: docker build --target production -t myapp:prod .

Best Practices: Making Multi-Stage Builds Sing

  • Order Matters - Cache Like a Pro Put the least frequently changing layers first:
dockerfile
# Good: Dependencies change less frequently than source code
COPY package*.json ./
RUN npm install
COPY . .

# Bad: This invalidates cache for every source code change
COPY . .
RUN npm install
Enter fullscreen mode Exit fullscreen mode
  • Use Specific Base Images
dockerfile
# Good: Explicit and smaller
FROM node:18-alpine AS builder

# Bad: Unpredictable and potentially larger
FROM node AS builder
Enter fullscreen mode Exit fullscreen mode
  • Clean Up in the Same Layer
dockerfile
# Good: Cleanup in same layer
RUN apt-get update && \
    apt-get install -y build-essential && \
    npm install && \
    apt-get remove -y build-essential && \
    apt-get autoremove -y && \
    rm -rf /var/lib/apt/lists/*

# Bad: Each RUN creates a layer
RUN apt-get update
RUN apt-get install -y build-essential
RUN npm install
RUN apt-get remove -y build-essential

Enter fullscreen mode Exit fullscreen mode
  • Use .dockerignore Create a .dockerignore file to prevent unnecessary files from being sent to the Docker daemon:
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
coverage
.nyc_output
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and How to Avoid Them

Pitfall 1: Copying Unnecessary Files Between Stages
dockerfile
# Bad: Copying everything
COPY --from=builder /app /app

# Good: Being selective
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
Enter fullscreen mode Exit fullscreen mode
Pitfall 2: Not Using Build Arguments Effectively
dockerfile
FROM node:18-alpine AS base
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV

FROM base AS development
# Development-specific setup

FROM base AS production
# Production-specific setup
Enter fullscreen mode Exit fullscreen mode

Build with: docker build --build-arg NODE_ENV=development --target development .

Pitfall 3: Ignoring Security in Multi-Stage Builds
dockerfile
# Good: Using non-root user
FROM alpine:latest
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder --chown=nextjs:nodejs /app/build ./build
USER nextjs
Enter fullscreen mode Exit fullscreen mode

The Bottom Line

Multi-stage builds aren't just about smaller images (though that 99% size reduction is pretty sweet). They're about:

  • Faster deployments: Smaller images mean faster pushes and pulls

  • Lower costs: Less storage, less bandwidth, less money

  • Better security: Fewer tools in production mean fewer attack vectors

  • Cleaner architecture: Separation of build and runtime concerns

Multi-stage builds transform Docker from a somewhat clunky virtualization tool into a precision instrument for creating exactly the runtime environment your application needs – nothing more, nothing less.
The next time you're writing a Dockerfile, ask yourself: "What does my application actually need to run in production?" Chances are, it's a lot less than what you're currently shipping. Multi-stage builds help you ship exactly that – and your deployment pipeline will thank you for it.
Remember: In the world of containers, less is definitely more. Your future self (and your infrastructure bill) will thank you for making the switch to multi-stage builds.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.