Docker

Mastering Multi-stage Dockerfiles: The Key to Smaller, Secure Container Images

Creating Docker images is a necessary skill in current software development. However, oversized images can lengthen build times, hinder deployments, and add unneeded vulnerabilities. Multi-stage Dockerfiles are a technique for creating lean, efficient, and secure images.

What is a Multi-Stage Dockerfile?

A multi-stage Dockerfile allows you to include multiple FROM statements in a single file. Each FROM statement generates a new build stage, and you can move artifacts (such as binaries or compiled files) from one stage to another. This helps to isolate the building and runtime environments, resulting in smaller, cleaner images.

Why Use Multi-Stage Builds?

  • Smaller Images: Exclude unnecessary build tools and dependencies from the final image.
  • Enhanced Security: Reduces attack surface by minimizing the runtime image contents.
  • Improved Maintainability: Simplifies managing build and runtime stages.

Let us demonstrate the difference with a real-world example. Here is a standard Dockerfile for a Go application:

# Build stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o main .

# Final stage
FROM alpine:3.19
COPY --from=builder /app/main /main
CMD ["/main"]

This creates a huge image including the whole Go toolchain. Now, let's change it with multi-stage builds:

# Build stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o main .

# Final stage
FROM alpine:3.19
COPY --from=builder /app/main /main
CMD ["/main"]

What's the difference? The first image could be 1GB or more, but the multi-stage version could be as small as 15MB!

How Multi-Stage Dockerfiles Work

A multi-stage Dockerfile contains:

  1. Build Stage: The stage where your application is built.
  2. Final Stage: A lightweight image containing only the required runtime files.

Real-world Examples

  • Express Js using prisma and redis
# Use Node.js LTS (Long Term Support) as the base image
FROM node:lts-bullseye-slim as base

# Install necessary dependencies
RUN apt-get update && apt-get install -y \
    openssl \
    dumb-init \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# Set working directory
WORKDIR /usr/src/app

# Install global dependencies
RUN npm install -g typescript

# Build stage
FROM base as builder

# Copy package.json and package-lock.json (if available)
COPY package*.json ./

# Install all dependencies (including devDependencies)
RUN npm install --legacy-peer-deps

# Copy prisma schema and generate client
COPY prisma ./prisma
RUN npx prisma generate

# Copy tsconfig.json and source code
COPY tsconfig.json ./
COPY src ./src

# Build the application
RUN npm run build

# Production stage
FROM base as production

# Set NODE_ENV to production
ENV NODE_ENV production

# Copy package.json and package-lock.json (if available)
COPY package*.json ./

# Install only production dependencies
RUN npm install --only=production --legacy-peer-deps

# Copy prisma schema and generate client
COPY prisma ./prisma
RUN npx prisma generate

# Copy built application from builder stage
COPY --from=builder /usr/src/app/dist ./dist

# Copy any additional necessary files (e.g., views, public assets)
COPY views ./views

# Create a non-root user
RUN useradd -m appuser

# Create logs directory and set permissions
RUN mkdir logs && chown appuser:appuser logs

# Switch to non-root user
USER appuser

# Expose the port the app runs on
EXPOSE ${PORT}

# Set default values for Redis configuration
ENV REDIS_HOST=redis
ENV REDIS_PORT=6379

# Start the application
CMD ["dumb-init", "node", "dist/index.js"]
  • Nextjs Application
# Stage 1: Build the application
FROM node:18-alpine AS builder

# Set working directory
WORKDIR /app

# Install dependencies
COPY package.json package-lock.json ./ 
RUN npm install --frozen-lockfile 

# Copy the rest of the application code
COPY . .

# Build the application
RUN npm run build

# Stage 2: Production image
FROM node:18-alpine AS runner

# Set working directory
WORKDIR /app

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

# Install only production dependencies
RUN npm install --only=production --frozen-lockfile

# Expose the port that the app runs on
EXPOSE ${PORT}

# Start the application
CMD ["npm", "start"]
  • Java Spring Boot Application
# Build stage
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests

# Final stage
FROM eclipse-temurin:17-jre-alpine
COPY --from=builder /app/target/*.jar app.jar
CMD ["java", "-jar", "app.jar"]

Best Practices for Multi-Stage Dockerfiles

  • Use Minimal Base Images: For runtime stages, use minimal images such as alpine or slim versions.
  • Leverage .dockerignore: Exclude unneeded files to speed up the build process and reduce image size.

Example .dockerignore:

node_modules
dist
.git
  • Name Your Build Stages: Use descriptive names for stages to improve readability.
FROM node:18 AS build-stage
  • Remove Sensitive Data: Avoid copying sensitive files (such as .env) to production stages.
  • Test Final Image: Test the runtime image to confirm it has all necessary files and dependencies

About

At DevelopersMonk, we share tutorials, tips, and insights on modern programming frameworks like React, Next.js, Spring Boot, and more. Join us on our journey to simplify coding and empower developers worldwide!

Email: developersmonks@gmail.com

Phone: +23359*******

Quick Links

  • Home
  • About
  • Contact Us
  • Categories

  • Java
  • TypeScript
  • JavaScript
  • React
  • Nextjs
  • Spring Boot
  • DevOps