Optimization
Practical techniques to shrink Docker image sizes, speed up builds, and harden images for production use.
1. Choose the Right Base Image
Your base image has the single biggest impact on final image size. Most people just grab node:20 or python:3.12 without realizing there are much leaner alternatives:
| Base Image | Approximate Size | When to Use |
|---|---|---|
node:20 | ~1.1GB | Avoid in production |
node:20-slim | ~240MB | When Alpine causes issues |
node:20-alpine | ~135MB | Most production workloads |
ubuntu:22.04 | ~80MB | When you need a familiar Linux environment |
debian:bookworm-slim | ~75MB | Need Debian packages with less bloat |
scratch | 0B | Statically compiled binaries (Go, Rust) |
Alpine Linux is the most popular minimal base. At roughly 5MB, it's tiny, but it uses a different C library (musl libc instead of glibc), which occasionally causes compatibility issues with some pre-compiled software. It's worth testing before committing to Alpine for complex applications.
# Before
FROM node:20 # ~1.1GB
# After
FROM node:20-alpine # ~135MB
That one-line change can save nearly a gigabyte.
2. Combine Related RUN Commands
Every RUN instruction creates a new layer. If you install packages in one layer and delete the cache in a separate layer, the cache files still exist in the earlier layer, they're just hidden. The image size doesn't shrink. Therfore, it's a best practice to combine the RUN statement like:
# Install and clean up in one layer, nothing left behind
RUN apt-get update && \
apt-get install -y --no-install-recommends curl git && \
rm -rf /var/lib/apt/lists/*
The --no-install-recommends flag is worth adding too. It tells apt to skip suggested packages that aren't strictly required, which can save a surprising amount of space.
3. Set Up a .dockerignore File
Before Docker builds your image, it sends your project folder to the build engine. If that folder contains node_modules, build artifacts, or your .env file, all of that gets included, even if your Dockerfile never explicitly copies it. A .dockerignore file works exactly like .gitignore and prevents this. Create one in the same directory as your Dockerfile:
node_modules/
.git/
.env
__pycache__/
tests/
Without this file, the build context could be hundreds of megabytes. With it, it often drops to just a few kilobytes. It also prevents secrets from accidentally leaking into your image.
4. Don't Install Tools You Don't Need at Runtime
Every package you add increases image size and introduces more potential vulnerabilities, so try not to include this.
# Install only what the app actually needs to run
RUN apt-get install -y --no-install-recommends libpq5
If you need to debug a running container, you can install tools temporarily with docker exec without it affecting the image itself:
docker exec -it my-container sh -c "apk add --no-cache curl"
5. Clean Up in the Same Layer
If you need build tools to compile something (like gcc for a Python package with native extensions), install them, use them, and remove them all in the same RUN instruction:
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc python3-dev && \
pip install --no-cache-dir -r requirements.txt && \
apt-get purge -y --auto-remove gcc python3-dev && \
rm -rf /var/lib/apt/lists/*
If you remove tools in a separate RUN, the files are hidden from the final image but still physically present in an earlier layer, which means they still take up space.
6. Pin Package Versions
Installing packages without specifying a version means your image might build differently tomorrow than it does today. That's a debugging nightmare in production.
# Always installs this exact version
RUN apt-get install -y curl=7.88.1-10+deb12u5
For production images especially, pinning versions ensures your builds are reproducible and you're always aware of exactly what's in your image.
7. Never Run as Root
By default, containers run as the root user. This is a real security risk, if an attacker finds a vulnerability in your application, running as root gives them far more power than they should have.
Always create a dedicated non-root user and switch to it before the final CMD:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Give the user ownership of the app directory
RUN chown -R appuser:appgroup /app
# Switch to the non-root user
USER appuser
CMD ["node", "server.js"]
8. Use COPY Instead of ADD
ADD and COPY both bring files into your image, but ADD has extra hidden behaviors as it automatically extracts .tar.gz archives and can even download files from URLs. These implicit behaviors are easy to misuse.
Unless you specifically need those features, always use COPY. It does exactly one thing and does it predictably:
# Don't use this
ADD ./archive.tar.gz /app/ # Auto-extracts the archive
ADD https://example.com/file /app/ # Downloads from a URL
# Better Option
COPY ./archive.tar.gz /app/ # Copies the file as-is
9. Use Multi-Stage Builds
If your application has a build step, like compiling TypeScript, building a React app, compiling a Go binary then multi-stage builds are by far the biggest size optimization available to you.
The idea is simple: use a fully-equipped build stage to produce your artifacts, then copy only those artifacts into a clean, minimal final stage. The build tools never make it into the production image.
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]
For a full walkthrough of multi-stage builds, see the Multi-Stage Builds page.
10. Scan Your Images for Vulnerabilities
Even a well-optimized image built from an official base can contain known security vulnerabilities (CVEs) in its packages. Make scanning part of your routine:
# Docker Scout: built into Docker Desktop and Docker Hub
docker scout cves myapp:latest
# Trivy: popular open source scanner, very fast
trivy image myapp:latest
# Grype: another solid open source option
grype myapp:latest
The best time to catch vulnerabilities is before they reach production. Add a scan step to your CI pipeline so every image gets checked automatically before it's deployed.
Key Takeaways
- Base image first: switching from
node:20tonode:20-alpinealone can save nearly 1GB. - Combine
RUNcommands: deleting files in a separate layer doesn't actually remove them from the image; it just hides them. - Always use
.dockerignoreas it prevents bloated build contexts and stops secrets from leaking into images. - Never run as root and create a dedicated app user and switch to it before
CMD. - Use
COPYnotADD, unless you specifically need archive extraction or URL downloads. - Multi-stage builds are the biggest win for apps with a build step as it ship only the output, not the tools.
- Scan regularly even a small image with known CVEs is still a liability.
Frequently Asked Questions
Do I need to apply all of these techniques to every image?
No, prioritize based on impact. The biggest wins in order are: choosing a smaller base image, using multi-stage builds (if you have a build step), setting up .dockerignore, and combining RUN commands. The others are good habits worth building over time.
Is Alpine always the best base image choice?
It's usually the smallest option, but not always the right one. Some packages behave differently under Alpine's musl libc vs the standard glibc. If you run into strange errors with an Alpine-based image, try the -slim variant instead as it is a Debian-based and much more compatible, while still being significantly smaller than the full image.
My image is still large after switching to Alpine. Why?
The base image is just the starting point. If you're copying in node_modules or running apt-get install without cleaning up, those layers add to the total size regardless of the base. Check your layer sizes with docker history myimage to see where the bulk is coming from.
How often should I scan my images?
At minimum, scan every time you build a new image in CI before deploying. For long-running production images, set up a scheduled scan (weekly is common) since new CVEs are discovered constantly even if your image hasn't changed.
Is it safe to use scratch as a base image?
For statically compiled binaries like Go or Rust programs, yes, scratch is an empty image with nothing in it at all, which is as minimal as it gets. But it means no shell, no package manager, and no OS utilities, so debugging becomes much harder. It's best suited for simple, self-contained services in well-understood production environments.