Reducing Docker Image Size
Reducing Docker image size is not only about storage but also about security and cloud cost and more…
A 10-line Python script does not need a 1 GB image. But that is exactly what you get when you write FROM python, then COPY . ., and call it a day. The image works, so nobody complains, until it has to be pulled onto a hundred machines. Here are the tricks I use to put my images on a diet.
Why Reduce Size
| Reason | Why It Matters |
|---|---|
| Security | Less stuff inside means less to get hacked. Every extra package is one more thing with possible vulnerabilities |
| Cost | Registry storage and data transfer are billed (ECR, etc.). Smaller image, smaller bill |
| Faster deployment | A fresh node must pull the whole image before the container can start. Big image, slow start |
| Faster scale-up | When traffic jumps and new machines join, pull time is pure waiting |
| Less to maintain | Fewer packages to patch, scan and worry about |
Techniques At A Glance
| # | Technique | One Line |
|---|---|---|
| 1 | Lightweight base image | Don’t start from a fat image |
| 2 | Remove dev dependencies | Test and lint tools don’t belong in production |
| 3 | Clean package manager caches | apt, pip and npm all leave leftovers |
| 4 | Multi-stage build | Build in one image, ship another |
| 5 | Distroless | No shell, no package manager, nothing extra |
| 6 | .dockerignore | Stop COPY . . from carrying junk and secrets |
| 7 | Small extras | A few more tricks at the end |
Lightweight Base Image
Your base image is usually the biggest part of your final image, so start here. Roughly (check yours with docker image ls, numbers move with versions):
| Base Image | Rough Size | Note |
|---|---|---|
ubuntu | ~80 MB | Bare OS, you add Python yourself |
python:3.12 | ~1 GB | Full Debian plus compilers and many tools |
python:3.12-slim | ~130 MB | Debian with the extras removed |
python:3.12-alpine | ~60 MB | Alpine Linux, the smallest of the common ones |
Same story with Node: node, node:<version>-slim and node:<version>-alpine.
Why not Ubuntu for everything? If your app is Python, a ready-made python image already has Python set up, so you don’t install and clean it up yourself.
Why not always Alpine then? Because it is not a free lunch:
| Gotcha | What Happens |
|---|---|
| Different C library | Alpine uses musl, most Linux uses glibc. Some software behaves differently or breaks |
| Python wheels | Many prebuilt packages are made for glibc. On Alpine pip may compile from source, which is slow, needs compilers, and can end up bigger than slim |
| Harder debugging | Different tools, different surprises |
Tip
My rule: start with -slim. Move to Alpine only if you have tested your app on it, not because the number looks nice.
Remove Dev Dependencies
Production does not run your tests or linter, so don’t ship them. Keep two lists:
# requirements.txt (what the app needs to run)
requests==2.32.3# requirements-dev.txt (extra stuff for you only)
-r requirements.txt
pytest
ruffYour Dockerfile installs only requirements.txt. On your laptop you install the dev one.
Same idea elsewhere, for example npm: npm ci --omit=dev.
Clean Package Manager Caches
apt, pip and npm all keep downloaded files around “just in case”. In an image, nobody needs them.
# apt: skip suggested extras, delete package lists
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# pip: don't keep a cache at all
RUN pip install --no-cache-dir -r requirements.txt
# npm: clean after install
RUN npm ci --omit=dev && npm cache clean --forceWarning
Clean in the same RUN line. Every RUN makes a new layer and layers are never edited, only stacked. If you install in one RUN and delete in the next, the files are still sitting in the earlier layer and your image does not shrink.
| Dockerfile | Result |
|---|---|
RUN apt-get install ... then a separate RUN rm -rf /var/lib/apt/lists/* | Cache stays in the first layer, no size saved |
One RUN apt-get install ... && rm -rf /var/lib/apt/lists/* | Cache is never saved in any layer |
Multi-Stage Build
Idea: do the messy work (compilers, caches, dev tools) in a first image, then start a fresh clean image and copy over only the result. Everything else is thrown away.
flowchart LR
builder["Builder Stage: compilers, pip, caches"] -->|"COPY --from=builder /opt/venv"| final["Final Stage: runtime + venv + app"]
builder -.->|thrown away| trash["Discarded"]
Let’s see it with our own eyes. Create a folder with these files:
mkdir size-demo && cd size-demo
cat > app.py << EOF
import requests
print("requests version:", requests.__version__)
EOF
cat > requirements.txt << EOF
requests==2.32.3
EOFThe single-stage version, everything in one image:
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]The multi-stage version, compilers stay in the builder:
FROM python:3.12-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]Note
requests does not really need a compiler. I install build-essential on purpose to pretend we have a package that does (database drivers and many scientific packages do). That is the case where multi-stage pays off the most.
For intrepreted language like Python, it’s not always necessary to have builder container or stage unless you have some C code to compile. See more in this StackOverflow post.
Build both and compare:
docker image build -t size-single -f Dockerfile.single .
docker image build -t size-multi -f Dockerfile.multi .
docker image ls --filter "reference=size-*"The multi-stage image should be much smaller, because the compilers never made it to the final stage. Both do the same job:
docker container run --rm size-multirequests version: 2.32.3Want to know where the weight is? Look at the layers:
docker image history size-singleClean up:
docker image rm size-single size-multi
cd .. && rm -rf size-demoDistroless
Even Alpine is still a small Linux, with a shell, a package manager and tools. Distroless images (from Google) throw all of that away and keep only your app and what it needs to run.
| What Is Missing | Effect |
|---|---|
| Shell | docker container exec -it <name> sh will not work |
| Package manager | Nobody can install a tool inside a running container |
| Extra tools like curl | Smaller size and fewer things for an attacker to use |
Debugging is harder, yes. But in almost all cases the end users are not there to debug, and a container is not a server you log into anyway.
Tip
Most distroless images have a :debug tag with a tiny shell added. Use it when you really must look inside, never in production.
Warning
Distroless shines for compiled languages like Go and Java. For Python it works but is less polished, and your builder stage must use the same Python version as the distroless image or the copied packages won’t load. Read the image’s docs before you commit to it.
Don’t Forget .dockerignore
A broad COPY . . copies everything in your folder: .git history, virtual environments, test data, and even .env or private key files. All of it can end up inside your image.
.git
__pycache__/
*.pyc
venv/
.venv/
node_modules/
.env
*.pem
*.keyThis helps in two ways: smaller image, and secrets never enter the build at all.
Warning
Copying a secret and deleting it in a later RUN does not remove it. It stays in the earlier layer and anyone can dig it out of the image. Keep it out with .dockerignore.
A Few More Tricks
| Trick | Why |
|---|---|
COPY --chown=user:group instead of RUN chown -R | A chown -R after copying rewrites every file into a new layer, so the files are stored twice |
| Don’t install debug tools (curl, vim, ping) | You will forget to remove them. Add them in a debug image if needed |
--no-install-recommends with apt | apt skips the “suggested” extras |
Static binary on scratch (Go, Rust) | scratch is a completely empty image, so the final image is basically your binary |
| One job per image | Don’t pack a database and your app together |
Check docker image history | Finds the fat layer in seconds |
So that is my diet plan: a slim base, no dev stuff, clean as you go, ship only what you built, and keep junk out of the build. Run docker image ls before and after, and enjoy watching the number drop.
