Backend & DevOps Blog

Real-world experiences with MongoDB, Docker, Kubernetes and more

Adding Multi-Stage Builds to Your Dockerfile

Docker image bloat is a common issue that can slow down deployments and waste resources. When I found our application image had grown to over 1.5GB, I knew it was time to implement multi-stage builds. What should have been a straightforward optimization turned into an unexpected debugging session that taught me some valuable lessons about Docker.

The Problem: Oversized Docker Images

Our Next.js application had a simple Dockerfile that worked fine in development but had several issues in production:

  • It included all development dependencies (~800MB of node_modules)
  • The image contained the entire build context including test files and documentation
  • Build artifacts and source code were both present, doubling some content

Here's what our original Dockerfile looked like:

FROM node:16

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]

First Attempt: Basic Multi-Stage Build

My first attempt at a multi-stage build looked promising. The idea was simple - use one stage to build the application and another to run it:

# Build stage
FROM node:16 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Production stage
FROM node:16-slim
WORKDIR /app
COPY --from=builder /app/package*.json ./
RUN npm install --production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["npm", "start"]

But when I tried to build this, I encountered my first error:

Error: Cannot find module 'next/dist/compiled/jest-resolve'
Require stack:
- /app/node_modules/next/dist/server/require-hook.js
- /app/node_modules/next/dist/server/next-server.js
- /app/node_modules/next/dist/server/next.js
- /app/node_modules/next/dist/server/lib/start-server.js
- /app/node_modules/next/dist/cli/next-start.js
- /app/node_modules/next/dist/bin/next

The Real Issue: Missing Dependencies

After digging into the error, I realized that Next.js requires some development dependencies even in production mode. The --production flag was causing critical dependencies to be omitted.

Additionally, I discovered a few other issues:

  1. I was missing the next.config.js file in the production image
  2. Environment variables weren't being properly passed between build stages
  3. The build context was still bloated with unnecessary files

Solution: Refined Multi-Stage Build

After several iterations, I created a properly working multi-stage Dockerfile:

# Build stage
FROM node:16-alpine AS builder
WORKDIR /app

# Add .dockerignore file to optimize build context
# (Make sure you have a proper .dockerignore file)

# First copy only package files and install dependencies
# This optimizes Docker layer caching
COPY package.json package-lock.json ./
RUN npm ci

# Copy necessary files for the build
COPY next.config.js ./
COPY tsconfig.json ./
COPY public ./public
COPY src ./src

# Build the application
RUN npm run build

# Production stage
FROM node:16-alpine AS runner
WORKDIR /app

# Set to production environment
ENV NODE_ENV production

# Create a non-root user for better security
RUN addgroup --system --gid 1001 nodejs &&     adduser --system --uid 1001 nextjs

# Copy only the necessary files from the builder stage
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/package-lock.json ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next

# Install only production dependencies
RUN npm ci --only=production &&     npm cache clean --force

# Set proper permissions
RUN chown -R nextjs:nodejs /app

# Switch to non-root user
USER nextjs

# Expose the port the app will run on
EXPOSE 3000

# Start the application
CMD ["npm", "start"]

Critical Addition: A Proper .dockerignore File

One of the most impactful changes was creating a thorough .dockerignore file:

# Git and version control
.git
.gitignore
.github

# Dependencies
node_modules
npm-debug.log
yarn-debug.log
yarn-error.log

# Testing
coverage
__tests__
jest.config.js

# Build output
.next
out

# Environment variables
.env
.env.local
.env.development
.env.test

# Development tools and IDE files
.idea
.vscode
*.swp
*.swo

# Documentation and other non-code files
README.md
CHANGELOG.md
docs

This file significantly reduced the build context size and improved build times.

Environment-Specific Troubleshooting

During testing in production, we discovered an issue where the application behaved differently than in development. The problem was that some environment variables weren't being passed correctly.

In our CI pipeline, we needed to add this step to ensure variables were available at build time:

# Build with production settings and environment variables
docker build \
  --build-arg NODE_ENV=production \
  --build-arg API_URL=${API_URL} \
  --build-arg NEXT_PUBLIC_ANALYTICS_ID=${ANALYTICS_ID} \
  -t myapp:latest .

And then update the Dockerfile to receive these arguments:

# Build stage
FROM node:16-alpine AS builder
WORKDIR /app

# Add build arguments for environment variables
ARG NODE_ENV
ARG API_URL
ARG NEXT_PUBLIC_ANALYTICS_ID

# Set environment variables from build args
ENV NODE_ENV=${NODE_ENV}
ENV API_URL=${API_URL}
ENV NEXT_PUBLIC_ANALYTICS_ID=${NEXT_PUBLIC_ANALYTICS_ID}

# ... rest of the Dockerfile stays the same

Results: Significant Improvements

The final multi-stage Dockerfile delivered impressive results:

  • Image size reduced from 1.5GB to 350MB (77% reduction)
  • Build time decreased by 35% due to better caching
  • Deployment time cut in half due to smaller image size
  • Application startup time improved by 20%
  • Better security with non-root user and fewer dependencies

Lessons Learned

This process taught me several important lessons about Docker optimization:

  1. Know your application dependencies: Understand which dependencies are truly needed for production.
  2. Use .dockerignore effectively: This is often overlooked but has a significant impact on build performance.
  3. Order matters in Dockerfiles: Structure your Dockerfile to maximize layer caching.
  4. Test in production-like environments: Some issues only appear in specific environments.
  5. Security should be built in: Using non-root users and minimizing dependencies reduces attack surface.

Conclusion

Multi-stage builds are a powerful tool for optimizing Docker images, but implementing them correctly requires careful attention to detail. By understanding the application's requirements and following the best practices outlined above, you can significantly reduce image size, improve build times, and enhance security.

The effort spent on optimizing your Dockerfile pays dividends in faster deployments, lower costs, and improved performance – well worth the initial investment in time and debugging.