Skip to main content

Command Palette

Search for a command to run...

Step-by-Step Guide to Building an Efficient Multi-Stage Dockerfile

Best Practices for Structuring a Multi-Stage Dockerfile Efficiently

Published
3 min read
Step-by-Step Guide to Building an Efficient Multi-Stage Dockerfile
H

I'm an IT professional and business analyst, sharing my day-to-day troubleshooting challenges to help others gain practical experience while exploring the latest technology trends and DevOps practices. My goal is to create a space for exchanging ideas, discussing solutions, and staying updated with evolving tech practices.

Introduction

Optimizing Docker images is crucial for reducing deployment overhead, improving security, and enhancing performance. A well-structured multi-stage Dockerfile helps achieve these goals by separating build and runtime environments, leveraging Docker layer caching, and minimizing the final image size. In this article, we will explore the optimization strategies and best practices for building an efficient multi-stage Dockerfile.


Identify Optimization Goals:

  1. Reduce Final Image Size: Remove unnecessary build tools and dependencies from the runtime image.

  2. Improve Security: Minimize the attack surface by using minimal base images and dropping unnecessary privileges.

  3. Leverage Layer Caching: Structure the Dockerfile to maximize caching efficiency for faster builds.


Multi-Stage Build Strategy:

1. Build Stage:

  • Use a larger base image with required compilers and tools.

  • Install dependencies and compile/package the application.

2. Runtime Stage:

  • Use a minimal base image (e.g., Alpine, scratch).

  • Copy only the compiled artifacts from the build stage.


Key Optimization Strategies:

  • Use Small Base Images: Alpine (Node.js), Distroless (Java/Python) minimize unnecessary libraries.

  • Remove Unnecessary Files: Delete temporary files, caches, and unused dependencies after the build process.

  • Leverage Layer Caching: Copy dependency files first to optimize build time and avoid unnecessary reinstallations.

  • Copy Only Required Files: Avoid copying the entire source code; instead, copy only necessary artifacts.


Implementation: Multi-Stage Dockerfile Example

Optimized Dockerfile for a Node.js App:

# ----------------------
# Stage 1: Build Stage
# ----------------------
FROM node:18-alpine AS builder

WORKDIR /app

# Copy package files first to leverage Docker caching
COPY package*.json ./

# Install dependencies (including devDependencies)
RUN npm ci

# Copy source code and build
COPY . .
RUN npm run build  # Compiles code to ./dist

# ----------------------
# Stage 2: Runtime Stage
# ----------------------
FROM nginx:alpine

# Copy compiled assets from the build stage
COPY --from=builder /app/dist /usr/share/nginx/html

# Copy custom nginx configuration (optional)
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Expose port and run nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Explanation of Optimizations

  1. Multi-Stage Separation:

    • Builder Stage: Uses node:18-alpine to install dependencies and compile code.

    • Runtime Stage: Uses nginx:alpine (5MB) to serve static files, discarding unnecessary Node.js build tools.

  2. Layer Caching:

    • Copying package*.json first ensures dependency installations are cached unless package files change.
  3. Minimal Base Images:

    • Alpine-based images are lightweight, reducing the attack surface and unnecessary package installations.
  4. Efficient File Copying:

    • Only the final dist folder is copied into the runtime stage, avoiding unnecessary files from being included.
  5. Automatic Cleanup:

    • Since the builder stage is discarded after artifact copying, manual cleanup isn't required.

Result

  • Before Optimization: A Node.js image with build tools might be ~1GB.

  • After Optimization: The final image (Nginx + static files) is ~20MB.


Additional Best Practices

  1. Use .dockerignore to exclude unnecessary files:

     node_modules
     Dockerfile
     .git
    
  2. Enhance Security:

    • Run containers as non-root:
    RUN chown -R nginx:nginx /usr/share/nginx/html
    USER nginx
  1. Further Size Reduction:

    • Use scratch (empty base image) for Go/Rust binaries.

    • Use distroless images for Python/Java (Google’s minimal images).


Testing the Optimized Image

1. Build the Image:

docker build -t my-optimized-app .

2. Verify the Image Size:

docker images | grep my-optimized-app

3. Run the Container:

docker run -p 3000:80 my-optimized-app

Conclusion

Using a well-structured multi-stage Dockerfile significantly improves efficiency, security, and maintainability. By leveraging small base images, layer caching, and artifact separation, teams can create lightweight, production-ready containers. Regularly refining these practices ensures optimal performance and cost efficiency in Dockerized applications.