When shipping applications using containers, one often is confronted with overly large final images. Multi-stage builds are a common way to circumvent this issue, especially for compiled languages like Go or Java. In our latest blog post we show how to utilise multi-stage builds for python images to bring down image sizes and thereby improving security.

Using docker multi-stage builds to reduce image size

When writing container images, it is always preferable to have smaller images and to only include what is really needed. This has two main advantages:

  • images take up less storage space
  • security vulnerabilities of software that is not installed cannot be exploited

Example dockerfile

Let's take a very simple image that starts off with an official python-docker image, using a slim version of the current stable debian release, bullseye. We are going to use python 3.8, but this is not important. The image is pulled from docker hub.

The dockerfiles are provided in this repository in the docker directory and can be built using './bin/build.sh' on Linux.

Reducing the image size

The all-in-one solution

We are simply going to `COPY` over a `requirements.txt` and install it using `pip`, as this is a fairly common use case. I have chosen to install pyodbc since it has some system
dependencies that have to be installed.

Note: The dockerfiles are a minimal reproducible example and do not follow some common best practices!

FROM python:3.8-slim-bullseye

RUN apt-get update && \
    apt-get install -y gcc g++ unixodbc-dev

COPY requirements.txt /tmp/requirements.txt

RUN pip3 install --no-cache-dir -r /tmp/requirements.txt

After building the image, we get an image size of 422MB:

$ docker images original
REPOSITORY   TAG       IMAGE ID       CREATED              SIZE
original     latest    09ec99a1bafa   About a minute ago   422MB

Introducing: multi-stage

Multi-stage builds are a neat way to keep build dependencies from blowing up the size of your final image. I am not going to go into detail on how they work.

I am going to create wheels. From the pip documentation:

"Wheel is a built-package format, and offers the advantage of not recompiling your software during every install."

And this is exactly what we want. Compile it and then reuse it without compilation.

The original dockerfile is split in 2: the builder image will install the build dependencies and create the wheels. The final image will install the packages from the wheels without the need to install any build tools:

the wheels. The final image will install the packages from the wheels without the need to install any 
ARG WHEEL_DIST="/tmp/wheels"

FROM python:3.8-slim-bullseye as builder


RUN apt-get update && \
    apt-get install -y gcc g++ unixodbc-dev

COPY requirements.txt /tmp/requirements.txt

RUN python3 -m pip wheel -w "${WHEEL_DIST}" -r /tmp/requirements.txt

FROM python:3.8-slim-bullseye


COPY --from=builder "${WHEEL_DIST}" "${WHEEL_DIST}"


RUN pip3 --no-cache-dir install *.whl

The size of the image has gone down significantly:

$ docker images multi-stage
multi-stage   latest    b90d97997b3b   44 seconds ago   128MB

The difference is starker when considering the size of the base image:

docker images python:3.8-slim-bullseye
REPOSITORY   TAG                 IMAGE ID       CREATED      SIZE
python       3.8-slim-bullseye   caf584a25606   5 days ago   122MB

Increasing security

Smaller images can contribute to more security.
To illustrate this point, we are going to use trivy to scan our images for known security vulnerabilities.

The base image has 85 vulnerabilities:

  • LOW: 12
  • MEDIUM: 35
  • HIGH: 30

The original image that includes the build tools has 331 total vulnerabilities:

  • UNKNOWN: 2
  • LOW: 24
  • MEDIUM: 175
  • HIGH: 109
  • CRITICAL: 21

The multi-stage image again has 85 images that it "inherits" from the base image, but does not introduce any new ones.


When using python, do utilise wheels and multi-stage builds to decrease the image size and increase the security of your deployements.