Docker: multi-stage builds for tiny images
Docker multi-stage builds significantly reduce the size of container images by separating build-time dependencies from runtime requirements. This approach…
Docker multi-stage builds significantly reduce the size of container images by separating build-time dependencies from runtime requirements. This approach leads to smaller images, faster deployments, improved security by reducing the attack surface, and lower storage costs. This article details the implementation of multi-stage builds, focusing on practical examples for various development stacks and best practices for optimization.
Understanding the Multi-Stage Build Concept
A multi-stage build involves defining multiple FROM instructions in a single Dockerfile. Each FROM instruction starts a new build stage. Critically, files can be copied from one stage to another using the COPY --from=<stage_name_or_number> instruction. This mechanism allows build artifacts (like compiled binaries, static assets, or bundled application code) to be transferred to a much smaller, production-ready base image, leaving behind all the tools and intermediate files used during compilation or packaging.
The Problem Multi-Stage Builds Solve
Without multi-stage builds (prior to Docker 17.05), developers often employed two common patterns:
- Single-stage build: A single
Dockerfilethat included all build tools and runtime dependencies, leading to bloated images (e.g., agolang:alpineimage for compiling, then shipping the binary in the same image). - Builder pattern: Using separate
Dockerfiles or scripts to build artifacts, then manually copying them into a final production image. This was cumbersome and less integrated.
Multi-stage builds streamline the builder pattern directly within a single Dockerfile, making the process more efficient and easier to maintain.
Implementing Multi-Stage Builds: Practical Examples
Example 1: Go Application
Go applications compile into static binaries, making them ideal candidates for multi-stage builds using a scratch or alpine base image for the final stage.
# Stage 1: Build the Go application
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o /app/server .
# Stage 2: Create the final lean image
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
In this example:
golang:1.22-alpineis used for compilation. This stage is namedbuilder.go mod downloadfetches dependencies, andgo buildcompiles the application.CGO_ENABLED=0ensures a statically linked binary, crucial for compatibility withalpine.- The second stage starts with a fresh
alpine:3.19image. COPY --from=builder /app/server .transfers only the compiled binary from thebuilderstage to the final image.- The resulting image will typically be a few MBs, significantly smaller than the
golang:1.22-alpinebase image (around 300MB).
Example 2: Node.js Application (React/Vue/Angular Frontend)
Frontend applications often involve many development dependencies (e.g., Webpack, Babel, TypeScript) that are not needed at runtime. Multi-stage builds can drastically reduce image size by only shipping the compiled static assets and a web server.
# Stage 1: Build the frontend application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build # This command generates static assets in a 'dist' or 'build' folder
# Stage 2: Serve the static assets with Nginx
FROM nginx:1.25-alpine
# Remove default Nginx config to replace it with our own
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/build /usr/share/nginx/html # Adjust '/app/build' as per your project's build output
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Here:
node:20-alpineserves as the build environment (namedbuilder).npm installandnpm run buildexecute the build process.- The second stage uses a tiny
nginx:1.25-alpineimage. - A custom
nginx.confis copied to configure the web server. COPY --from=builder /app/build /usr/share/nginx/htmlcopies the static HTML, CSS, and JS files generated bynpm run buildinto the Nginx serving directory.
Example 3: Python Application with Dependencies
Python applications often have many C-extension dependencies that require build tools like gcc and Python development headers. These are not needed at runtime.
# Stage 1: Build dependencies with build tools
FROM python:3.11-slim-bullseye AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: Create a minimal runtime image
FROM python:3.11-slim-bullseye
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
This Python example shows a slight variation. Instead of just copying a binary, we copy the installed Python packages from the builder stage. This is useful when the slim image already contains the necessary runtime Python interpreter and system libraries, but we want to avoid installing build dependencies in the final image.
For more complex C-extension heavy packages, you might use python:3.11-buster as the builder and then switch to a python:3.11-slim-buster for the final image, ensuring the ABI compatibility of the installed modules.
Best Practices for Image Optimization
Leverage .dockerignore
Always use a .dockerignore file in your project's root directory. This prevents unnecessary files (like .git, node_modules in the root, IDE files, local dev data) from being sent to the Docker daemon build context, speeding up builds and reducing image layer size.
# .dockerignore example
**/.git
**/.vscode
**/node_modules
**/tmp
**/logs
Dockerfile
README.md
Pin Base Image Versions
Always use specific version tags for your base images (e.g., alpine:3.19, golang:1.22-alpine) rather than floating tags like latest or alpine. This ensures reproducible builds.
Order Layers Effectively
Place instructions that change less frequently (e.g., dependency installation) earlier in the Dockerfile. Docker caches layers, so changes to later instructions won't invalidate earlier, stable layers, leading to faster rebuilds.
# Good: Copy dependencies first, then application code
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# Bad: Copy everything then install, forcing re-install on every code change
COPY . .
RUN pip install -r requirements.txt
Minimize Layers
Combine multiple RUN commands using && \ to reduce the number of layers in your image. Each RUN instruction creates a new layer, and fewer layers generally mean smaller images and better performance.
RUN apt-get update && \
apt-get install -y --no-install-recommends some-package && \
rm -rf /var/lib/apt/lists/*
Clean Up During Build
Remove build caches and unnecessary files immediately after they are used within the same RUN instruction. This ensures they don't persist in the image layer.
- For
apt:rm -rf /var/lib/apt/lists/* - For
yum:yum clean all && rm -rf /var/cache/yum - For
npm:npm cache clean --force(thoughnpm installis often followed by anpm prune --productionif only production dependencies are needed, or simply not copied in the final stage). - For
pip:pip install --no-cache-dir
Use Minimal Base Images
Whenever possible, use minimal base images like alpine, slim variants (e.g., python:3.11-slim-bullseye), or even scratch for statically linked binaries. These images contain only the essentials, drastically reducing the attack surface and size.
| Base Image | Typical Size (Compressed) | Use Case |
|---|---|---|
scratch |
0 MB | Statically compiled binaries (Go, Rust). No shell, no package manager. |
alpine:<version> |
~3-7 MB | Minimal Linux distribution based on musl libc. Excellent for many runtimes. |
debian:<version>-slim |
~30-60 MB | Minimal Debian-based images, good for environments needing GNU libc compatibility. |
ubuntu:<version> |
~70-100 MB | Standard Ubuntu images. Larger, but offer broad compatibility and tools. |
Common Pitfalls and Troubleshooting
Missing Runtime Dependencies
One of the most common issues with minimal base images is missing shared libraries that your application or its dependencies require at runtime. For example, a Python package might depend on libpq-dev (PostgreSQL client libraries) which isn't present in python:slim. When this happens, your container will likely crash with errors like "No such file or directory" for a dynamic library. The solution is to identify the missing libraries (e.g., using ldd your_binary or strace in a debugging container) and install them in the final stage's Dockerfile:
# Example: Adding PostgreSQL client libraries to a python:slim image
FROM python:3.11-slim-bullseye
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq-dev && \
rm -rf /var/lib/apt/lists/*
# ... rest of your Dockerfile
Incorrect Copy Paths
Ensure that the source path in COPY --from=<stage> <source_path> <destination_path> precisely matches the location of the artifacts in the builder stage. Relative paths can be tricky; always use absolute paths for clarity (e.g., /app/build, not ./build).
Permissions Issues
If your application fails to start or interact with files, it might be a permission problem. Files copied from a builder stage often retain the user and group IDs of the builder user. If your final image runs as a non-root user (which is a good security practice), you might need to adjust permissions:
# Example: Ensure files are owned by a non-root user
FROM alpine:3.19
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/server .
USER appuser
CMD ["./server"]
The --chown flag (Docker Engine 18.09+) is very useful for this. Otherwise, a separate RUN chown -R appuser:appgroup /app command would be necessary.