Optimizing Docker Images
What this article covers
- Why smaller images matter.
- How layers and caching work.
- Multi-stage builds and .dockerignore.
- Base image selection.
- Security and performance best practices.
Introduction: Optimizing Docker Images
Large Docker images slow down builds, deployments, and downloads. They expand your attack surface by including unnecessary software and waste storage capacity. With targeted optimization, you can often shrink images from several gigabytes to a few hundred megabytes without sacrificing functionality.
This article shows how to make Docker images small, fast, and secure.
Key Concepts
- Layer: A distinct level within a Docker image.
- Caching: Reusing unchanged layers during subsequent builds.
- Multi-stage build: Multiple build phases in a single Dockerfile.
- .dockerignore: File that excludes unwanted files from the build context.
- Base image: The foundation layer for your image.
- Distroless: Minimal image without a shell or unnecessary tools.
- Squashing: Combining multiple layers into one.
Why Optimize Images?
- Faster builds and deployments.
- Lower storage requirements.
- Reduced attack surface.
- Less bandwidth needed for distribution.
- Easier maintenance and auditing.
Choosing a Base Image
Large vs. Small
| Base | Size | Advantage | Drawback |
|---|---|---|---|
| ubuntu | ~78 MB | Plenty of tools | Relatively large |
| debian:slim | ~30 MB | Well balanced | Fewer tools |
| alpine | ~5 MB | Very compact | musl compatibility issues |
| distroless | under 5 MB | Minimal footprint | No shell, harder debugging |
Recommendations
- For Python:
python:3.11-slim. - For Node.js:
node:20-alpineorslim. - For Go:
golang:alpineorscratchfor compiled binaries. - For critical environments:
distroless.
Multi-Stage Builds
# Builder
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
Only runtime files end up in the final image.
Optimizing Layers and Caching
Each instruction in a Dockerfile creates a layer. Place frequently changing commands at the end:
# Dependencies first, rarely change
COPY package*.json ./
RUN npm ci
# Source code last, changes often
COPY . .
This makes effective use of layer caching.
.dockerignore
Prevents unnecessary files from entering the build context:
node_modules
.git
*.log
.env
Dockerfile
.dockerignore
README.md
tests/
Combine Commands
Instead of:
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean
use:
RUN apt-get update && apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*
This creates a single layer and removes caches.
Remove Unneeded Tools
- Keep build tools like compilers only in builder stages.
- Run
apt-get cleanand remove package lists. - Use
npm ciinstead ofnpm install. - Exclude documentation and examples from the image.
- Don’t include test directories in production images.
Non-Root User
FROM node:20-slim
RUN useradd -m appuser
USER appuser
This reduces risk if a container is compromised.
Labels and Metadata
LABEL maintainer="team@example.com"
LABEL org.opencontainers.image.source="https://github.com/user/repo"
LABEL org.opencontainers.image.version="1.0.0"
Add Health Checks
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:3000/health || exit 1
Analyzing Images
Tools for inspection:
docker history my-image:latest
dive my-image:latest
dive reveals which layers consume the most space.
Example: Optimized Dockerfile
# Builder
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Runtime
FROM python:3.11-slim
WORKDIR /app
RUN useradd -m appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY --chown=appuser:appuser . .
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
HEALTHCHECK CMD curl -f http://localhost:8000/health || exit 1
CMD ["python", "main.py"]
Security
- Use fresh base images regularly.
- Scan images for vulnerabilities.
- Never embed secrets in images.
- Install only necessary packages.
- Consider a read-only filesystem.
- Avoid
latesttags.
Common Pitfalls
- COPY . . too early: Invalidates cache on every code change.
- No .dockerignore: Build context becomes massive.
- Build tools in final image: Results in larger, less secure images.
- Running as root: Increases risk.
- Too many layers: Each RUN creates one.
- Oversized base images: Unnecessary when slim variants exist.
- Skipping apt clean: Layers stay larger than needed.
Further Reading and Resources
- BotServ.de Docker Commands
- BotServ.de Docker CI/CD
- BotServ.de Docker Security
- BotServ.de Docker Volumes
FAQ: Optimizing Docker Images
How small should an image be? As small as possible without losing functionality. Often under 100 MB for simple applications.
Is Alpine always better? Not necessarily. musl compatibility can cause issues with certain libraries.
Do I need multi-stage builds? Usually yes for compiled languages and Node.js.
What is distroless? An extremely minimal image without a shell, containing only runtime files.
How do I check image size?
Use docker images, docker history, or dive.
Sources and Further Reading
- Docker Best Practices: https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
- Distroless Images: https://github.com/GoogleContainerTools/distroless
- dive: https://github.com/wagoodman/dive
- Docker BuildKit: https://docs.docker.com/build/buildkit/
Summary: Optimizing Docker Images
Optimized Docker images are smaller, faster, and more secure. The key measures are selecting a lean base image, using multi-stage builds, leveraging layer caching, writing a good .dockerignore, and removing unnecessary tools. Non-root users, health checks, and labels complete the picture. Regular vulnerability scans and analysis with dive keep you in control of size and security.


