We’ve all been there: you run a docker images command and see a production image clocking in at 1.5GB for a simple Node.js or Python app. Not only does this slow down your CI/CD pipeline, but it also increases your attack surface and pushes your cloud storage costs higher. In my experience, optimizing docker image size for production isn’t just about saving disk space—it’s about improving the reliability and speed of your entire deployment lifecycle.
Over the last few years of scaling microservices, I’ve found that the difference between a ‘working’ Dockerfile and a ‘production-ready’ one usually comes down to a few specific strategies. Here are 10 tips I use to keep my images lean and mean.
1. Use Multi-Stage Builds
This is the single most effective way to reduce size. Multi-stage builds allow you to use a heavy image (with compilers, build tools, and caches) to compile your app, and then copy only the final binary or transpiled files into a fresh, lightweight runtime image.
# Bad: Everything in one layer
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npm", "start"]
# Good: Multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm install --production
CMD ["node", "dist/main.js"]
2. Switch to Alpine Linux (or Distroless
If you are using ubuntu or debian as your base, you’re carrying around a lot of utilities you’ll never use in production. I almost always reach for alpine, which is a security-focused, lightweight Linux distribution.
For those who need even more security, I recommend Distroless images. These contain only your application and its runtime dependencies—no shell, no package manager, and no unnecessary binaries. This is a great way to complement the strategies I discussed in my guide on how to secure docker containers in production.
3. Minimize the Number of Layers
Every RUN, COPY, and ADD instruction creates a new layer in your image. While Docker caches these layers, too many of them can bloat the final image size. The trick is to chain related commands using && and the \ line break.
Instead of doing this:
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
Do this:
RUN apt-get update && apt-get install -y \
curl \
git && \
rm -rf /var/lib/apt/lists/*
4. Clean Up After Yourself
One of the most common mistakes I see is leaving package manager caches inside the image. As shown in the example above, always delete the cache in the same RUN command where you installed the software. If you delete the cache in a separate layer, it doesn’t actually remove the data from the previous layer—it just hides it.
5. Use a .dockerignore File
Why are you copying your node_modules, .git folder, and local .env files into your image? A .dockerignore file prevents unnecessary files from being sent to the Docker daemon, reducing the build context size and the final image footprint.
Essential .dockerignore entries:
node_modules/.git/*.logdist/orbuild/(if you build inside the container)
6. Avoid Installing Recommended Packages
When using apt-get install, Debian and Ubuntu often install “recommended” packages that aren’t strictly necessary for the software to run. Use the --no-install-recommends flag to keep things lean.
RUN apt-get update && apt-get install -y --no-install-recommends curl
7. Optimize Your Base Image Choice
Don’t just use python:latest. Use the -slim or -alpine variants. I’ve found that python:3.9-slim is often a better balance between compatibility and size than the full image, which includes a massive amount of build-essential tools.
8. Leverage BuildKit for Better Efficiency
Modern Docker versions come with BuildKit, which allows for more efficient builds. For those comparing advanced build tools, you might want to check out my breakdown of Kaniko vs BuildKit for container images to see which fits your CI pipeline better.
9. Use Specific Versions (Avoid :latest)
While not directly related to the binary size, using :latest leads to unpredictable image sizes over time. Pinning your version (e.g., node:18.16.0-alpine) ensures your image size remains consistent across different environments.
10. Analyze Your Image with Dive
If you’re still not sure why your image is so large, I highly recommend a tool called Dive. It allows you to explore a Docker image layer by layer and see exactly which files were added or modified in each step. It’s a game-changer for identifying “bloat” that isn’t obvious from the Dockerfile.
As shown in the terminal output below, Dive gives you a clear visual representation of the wasted space in your layers, allowing you to target exactly which RUN command is causing the spike.
Ready to optimize? Start by adding a .dockerignore file today—it’s the quickest win for any project.
Common Mistakes When Optimizing
- Deleting files in a separate layer: Remember,
RUN rm -rf /tmp/cachein a new layer doesn’t shrink the image. It must be part of the layer that created the files. - Over-optimizing for size at the cost of stability: Sometimes Alpine can cause weird C-library issues (musl vs glibc). If your app crashes mysteriously, try a
-slimDebian image first. - Ignoring the build context: Sending a 2GB folder to the Docker daemon will make your builds slow, even if the final image is small. Use
.dockerignore.
Measuring Your Success
To track your progress, I use a simple benchmark: docker images | grep my-app. My goal is always to keep production images under 200MB for microservices. If a jump of 50MB occurs, I use Dive to find the culprit.