Skip to content
Reducing Docker Image Size

Reducing Docker Image Size

October 28, 2026·anupam
anupam

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

ReasonWhy It Matters
SecurityLess stuff inside means less to get hacked. Every extra package is one more thing with possible vulnerabilities
CostRegistry storage and data transfer are billed (ECR, etc.). Smaller image, smaller bill
Faster deploymentA fresh node must pull the whole image before the container can start. Big image, slow start
Faster scale-upWhen traffic jumps and new machines join, pull time is pure waiting
Less to maintainFewer packages to patch, scan and worry about

Techniques At A Glance

#TechniqueOne Line
1Lightweight base imageDon’t start from a fat image
2Remove dev dependenciesTest and lint tools don’t belong in production
3Clean package manager cachesapt, pip and npm all leave leftovers
4Multi-stage buildBuild in one image, ship another
5DistrolessNo shell, no package manager, nothing extra
6.dockerignoreStop COPY . . from carrying junk and secrets
7Small extrasA 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 ImageRough SizeNote
ubuntu~80 MBBare OS, you add Python yourself
python:3.12~1 GBFull Debian plus compilers and many tools
python:3.12-slim~130 MBDebian with the extras removed
python:3.12-alpine~60 MBAlpine 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:

GotchaWhat Happens
Different C libraryAlpine uses musl, most Linux uses glibc. Some software behaves differently or breaks
Python wheelsMany 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 debuggingDifferent 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
ruff

Your 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 --force

Warning

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.

DockerfileResult
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
EOF

The single-stage version, everything in one image:

Dockerfile.single
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:

Dockerfile.multi
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-multi
requests version: 2.32.3

Want to know where the weight is? Look at the layers:

docker image history size-single

Clean up:

docker image rm size-single size-multi
cd .. && rm -rf size-demo

Distroless

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 MissingEffect
Shelldocker container exec -it <name> sh will not work
Package managerNobody can install a tool inside a running container
Extra tools like curlSmaller 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
*.key

This 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

TrickWhy
COPY --chown=user:group instead of RUN chown -RA 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 aptapt 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 imageDon’t pack a database and your app together
Check docker image historyFinds 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.

Last updated on