Skip to content
DevOps Docker Microservices

Setting Up Multi-Stage Docker Builds for Microservices

Ian David Rossi
Ian David Rossi October 15, 2018 · 5 min read

TL;DR

Multi-stage Docker builds are the difference between “it works on my machine” and “it’s shippable in prod.” Use them to build once, copy only what you need into a minimal runtime image, and keep build tools, compilers, and secrets out of production. Smaller images, faster deploys, less attack surface.

“If your production image still has curl, git, and half a compiler toolchain in it, you shipped your build server.”

Why You Should Care

Multi-stage Docker builds are a powerful feature that can help you optimize your container images. By separating the build and runtime stages, you can reduce image size, improve security, and streamline your CI/CD pipelines. In microservice architectures, where you may have dozens or hundreds of images, that difference compounds.

You’re not doing this for the aesthetic joy of a tidy Dockerfile. You’re doing it because:

  • Smaller images ship faster and roll back faster.
  • Fewer binaries in prod mean fewer things an attacker can abuse.
  • Clean build stages make it easier to reason about what changed when debugging.

Why Multi-Stage Builds?

Traditional Docker builds often result in large images that include unnecessary build tools and dependencies. Multi-stage builds solve this problem by allowing you to:

  • Reduce image size: Only include runtime dependencies in the final image.
  • Improve security: Minimize the attack surface by excluding build tools and shells.
  • Streamline CI/CD: Simplify the build process and improve pipeline efficiency with a consistent pattern.

Think of it as “build in one container, ship another.” The final image should look boring: just your app, its runtime, and nothing else.

A Quick Docker Refresher (In Context)

If you’re using Docker seriously, you already know the basics, but it’s worth grounding vocabulary:

  • Images are the immutable templates you build and tag.
  • Containers are running instances of those images.
  • A Dockerfile describes how to build an image.
  • A registry (like Docker Hub or a private registry) stores and distributes images.

Multi-stage builds simply let you define multiple build stages in a single Dockerfile and choose which bits survive into the final image.

Step-by-Step Guide

1. Create a Simple Microservice

For this tutorial, we’ll use a Node.js microservice as an example. Create a file named app.js:

const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('Hello, World!');
});

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

2. Write a Multi-Stage Dockerfile

Create a file named Dockerfile with the following content:

# Stage 1: Build
FROM node:16-alpine AS builder
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Runtime (distroless or minimal)
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist /app
USER 10001
CMD ["app.js"]

Use minimal/base images and avoid root to reduce risk. Prefer npm ci for reproducible installs. In larger systems, this pattern becomes your default template for Node services.

3. Build and Run the Docker Image

Build the Docker image:

docker build -t my-microservice .

Run the Docker container:

docker run -p 3000:3000 my-microservice

4. Integrate with CI/CD

Add the Docker build and push steps to your CI/CD pipeline. For example, in a GitHub Actions workflow:

name: Build and Push Docker Image

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Log in to DockerHub
        uses: docker/login-action@v2
        with:
          username: $
          password: $

      - name: Build and push Docker image
        run: |
          docker build --pull --no-cache -t my-microservice:$(git rev-parse --short HEAD) .
          docker tag my-microservice:$(git rev-parse --short HEAD) my-dockerhub-user/my-microservice:$(git rev-parse --short HEAD)
          docker push my-dockerhub-user/my-microservice:$(git rev-parse --short HEAD)
          docker tag my-dockerhub-user/my-microservice:$(git rev-parse --short HEAD) my-dockerhub-user/my-microservice:latest
          docker push my-dockerhub-user/my-microservice:latest

Advanced Techniques for Multi-Stage Builds

Optimizing Build Caching

Leverage Docker’s caching mechanism to speed up builds. For example, by copying package.json and running npm ci before copying the application code, you can avoid reinstalling dependencies if only the application code changes. This matters in CI where you may build dozens of times per day.

Minimizing Image Size

Use tools like docker-slim to analyze and reduce the size of your Docker images. Additionally, consider using smaller base images, such as alpine variants, to minimize the attack surface.

Security Best Practices

  • Scan Images: Use tools like Trivy or Docker Scan to identify vulnerabilities in your images.
  • Run as non-root: Avoid running containers as the root user to enhance security.
  • Keep images updated: Regularly update base images to include the latest security patches.
  • Sign artifacts: Use Sigstore/Cosign and verify signatures at admission.
  • Pin digests: Reference images by digest for reproducibility and secure rollbacks.

Treat image hardening as part of your pipeline, not a separate afterthought. A good pattern:

  1. Build via multi-stage Dockerfile.
  2. Run image scan in CI; fail the build on critical vulnerabilities.
  3. Sign images and push to a registry with immutability.
  4. Enforce digest pinning and signature verification in your cluster admission policies.

“Ship the same image everywhere. If ‘prod’ is special, you’ve already lost traceability.”

Multi-Stage Builds in a Microservices World

When you have tens or hundreds of services, conventions are oxygen. A few practical conventions:

  • One Dockerfile per service, using a shared base pattern (build stage + runtime stage).
  • Labels on images for service name, version, git SHA, and build date; surface these in your observability stack.
  • Separate build images per language/runtime (Node, Go, Java) but converge on a small set of runtime images.
  • Make your platform team own the base images and security posture; let service teams focus on application code.

You’ll know you’re doing this right when new services copy the same minimal pattern rather than reinventing Dockerfiles on every team.

Conclusion

Multi-stage Docker builds are an essential tool for optimizing microservices. By following this tutorial, you can create efficient, secure, and streamlined Docker images that integrate seamlessly with your CI/CD pipelines. With advanced techniques like caching, image size optimization, and security best practices, you can take your Docker builds to the next level.


Stay tuned for more DevOps tutorials and best practices.