Blog Post Header Image

> Build Multistage Python Docker Images Using UV

Reduce image size and build time with a multistage Dockerfile and uv

7 min read
Last updated:
Source code

Modern Python applications often suffer from bloated Docker images that are slow to build and large to deploy. When it comes to reducing build times, uv quickly established as the package manager of choice for many Python developers. Building a production ready Docker image based on uv however comes with a couple of challenges that are often overlooked. Using a multi-stage Dockerfile can reduce the size of the final image while keeping the build process fast. In this post we will guide you through the process of creating a production ready Docker image for a Python REST API and cover the challenges that come with a uv based multi-stage build.

Initializing A FastAPI Project With UV

Let’s set up a minimal FastAPI project using uv. This will serve as the foundation for our multistage build. Scaffold a python project with the the uv init command:

uv init --lib --python 3.13 --build-backend setuptools restapipy

This will initialize a git repository with the following structure:

$ tree restapipy
restapipy
├── pyproject.toml          # Project metadata and dependencies
├── README.md               # Project documentation
└── src                     # Source code directory
    └── restapipy
        ├── __init__.py     # Marks the directory as a Python package
        └── py.typed        # Indicates type annotations are present

Apart from pinning the Python version to 3.13 using the --python flag, we are specifying the --lib flag to create a library project. This organizes the project into a more conventional Python package structure and include a py.typed file to indicate that the package is type checked. By default --lib will enable the build-backend to hatchling, which we can override with --build-backend setuptools to the build system of our choice (setuptools).

Navigate into the project directory, as we will run all of the following commands from here.

cd restapipy

Let’s install the required dependencies for the project using uv add, this will additionally create a uv.lock file to lock the versions of the packages:

uv add fastapi uvicorn

Now that our project is initialized, let’s take a closer look at the pyproject.toml file that was created:

[project]
name = "restapipy"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
    { name = "Jendrik Potyka", email = "jpotyka@digon.io" },
    { name = "Fabian Preiß", email = "fpreiss@digon.io" },
]
requires-python = ">=3.13"
dependencies = [
    "fastapi>=0.116.1",
    "uvicorn>=0.35.0",
]

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

The pyproject.toml file is central to any modern Python project and can be utilized by package managing tools like pip and uv. In the project table we can see the project’s name, version, description, and dependencies.

With the project initialized, we need to create a few additional files to structure our application and Docker build process. These include the Dockerfile for the multistage build, the main FastAPI application file (main.py), and an entrypoint file (__main__.py) for running the API.

touch Dockerfile
touch src/restapipy/main.py
touch src/restapipy/__main__.py

After creating the necessary files, your project structure should look like this:

$ tree -I '__pycache__|*.egg-info'
.
├── Dockerfile              # Multistage Docker build script
├── pyproject.toml          # Project metadata and dependencies
├── README.md               # Project documentation
├── uv.lock                 # Locked dependency versions
└── src                     # Source code directory
    └── restapipy
        ├── __init__.py     # Marks the directory as a Python package
        ├── __main__.py     # Entry point for uvicorn
        ├── main.py         # FastAPI application logic
        └── py.typed        # Indicates type annotations are present

Defining The FastAPI App And OpenAPI Specification

Let’s define a simple FastAPI application that will serve as the core of our REST API. The following main.py file defines a single endpoint that returns a string. Additionally, we’ll implement a helper function to generate the OpenAPI JSON specification for our API. This is especially useful, if you plan to create a multi-service application, where another container depends on the REST API specification in it’s build step.

import json

from fastapi import FastAPI

app = FastAPI()

@app.get("/pythonic_breakfast")
def _() -> str:
    return "spam ham eggs"


def generate_openapi_json() -> None:
    openapi_dict = app.openapi()
    openapi_json = json.dumps(openapi_dict, indent=4)

    with open("openapi.json", "w") as f:
        f.write(openapi_json)

The following __main__.py file serves as the entry point for running the FastAPI application with Uvicorn. It ensures that the application can be executed directly from the container.

import uvicorn

def main() -> None:
    uvicorn.run(
        "restapipy.main:app",
        host="0.0.0.0",
        port=9876,
        log_level="info",
        reload=False,
    )


if __name__ == "__main__":
    main()

To make the OpenAPI JSON generation available as a CLI command, we add a [project.scripts] section to pyproject.toml. This allows us to execute uv run generate_openapi_json directly from the terminal.

[project]
name = "restapipy"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
    { name = "Jendrik Potyka", email = "jpotyka@digon.io" },
    { name = "Fabian Preiß", email = "fpreiss@digon.io" },
]
requires-python = ">=3.13"
dependencies = ["fastapi>=0.116.1", "uvicorn>=0.35.0"]

[project.scripts]
generate_openapi_json = "restapipy.main:generate_openapi_json"

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

Writing The Multistage Dockerfile For Python With UV

In the Dockerfile we can divide the build process into two main stages: The builder stage and the runtime stage. Both images use a minimal Alpine Linux Python image as the base (here specifically 3.13.5-alpine3.22).

# 3.13.5-alpine3.22
FROM python@sha256:610020b9ad8ee92798f1dbe18d5e928d47358db698969d12730f9686ce3a3191 AS builder

# 0.8.3
COPY --from=ghcr.io/astral-sh/uv@sha256:ef11ed817e6a5385c02cd49fdcc99c23d02426088252a8eace6b6e6a2a511f36 /uv /uvx /bin/
ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1 \
    UV_PYTHON_DOWNLOADS=never

WORKDIR /app

COPY pyproject.toml uv.lock ./
RUN uv sync --no-dev --locked --no-editable --no-install-project

COPY README.md src ./
RUN uv sync --no-dev --locked --no-editable

RUN uv run --no-sync generate_openapi_json

# 3.13.5-alpine3.22
FROM python@sha256:610020b9ad8ee92798f1dbe18d5e928d47358db698969d12730f9686ce3a3191 AS runtime

COPY --from=builder --chown=root:root /app/.venv /app/.venv
COPY --from=builder --chown=root:root /app/openapi.json /app/openapi.json

RUN chmod -R o=rx /app

RUN adduser -D -H worker
USER worker
EXPOSE 9876

ENV PYTHONPATH=/app/.venv/lib/python3.13/site-packages
CMD ["python", "-m", "restapipy"]

Understanding The builder Stage Of The Dockerfile

In the builder stage, we install uv manually from a pre-built container provided by Astral (the company behind uv). We have pinned down the version of both, the base image, as well as the uv package using the sha256 hash. This ensures reproducible builds. You can find the releases of the uv images under uv’s container registry. Look for the images, that tag the uv version only (e.g. latest, 0.8.3, 0.8) and do not use images that additionally specify a python version or a distribution (e.g. 0.8.3-debian-slim, 0.8-python3.13-alpine or python3.11-bookworm).

We additionally specify the following environment variables:

  • UV_LINK_MODE=copy - Disables the UV warning that hardlinks are not available (we cannot create hardlinks between the host system and a container).
  • UV_COMPILE_BYTECODE=1 - Enable bytecode compilation (.py files are compiled to .pyc files), to speed up the startup of the application.
  • UV_PYTHON_DOWNLOADS=never - Prevents UV from downloading python binaries precompiled by Astral. Shifts the responsibility of providing the correct python binary to the base image.

In the next two steps, we copy the pyproject.toml and uv.lock files into the container and run uv sync with the --no-dev, --locked, --no-editable, and --no-install-project flags. This ensures that the container does not contain a full development environment, but only the necessary dependencies. With the --locked flag, we tell UV to only install the dependencies listed in the uv.lock file, and not any additional packages that might be specified in the pyproject.toml.

COPY pyproject.toml uv.lock ./
RUN uv sync --no-dev --locked --no-editable --no-install-project

After that, we copy the README.md and src directories into the container and run uv sync again. Running uv sync twice (this time with our source code), has the advantage that changing a few lines in our project does not trigger a full reinstallation of all dependencies in the build context, as we can reuse the cached layers from a previous build as long as we do not change anything in the pyproject.toml or uv.lock files.

COPY README.md src ./
RUN uv sync --no-dev --locked --no-editable

In the final layer of the builder stage, we run the generate_openapi_json command to generate the OpenAPI specification file (openapi.json) based on the FastAPI application into the app directory. The --no-sync flag tells uv not to re-sync the environment before running the script, which avoids unnecessary dependency installs.

RUN uv run --no-sync generate_openapi_json

Understanding The runtime Stage Of The Dockerfile

The runtime stage is where we build the final, minimal production image for deployment. Starting with the same alpine base image, we copy only the bare minimum of the artifacts from the builder stage: The virtual environment (.venv) and the generated openapi.json.

To secure the image, we change the permissions of the copied files and create a non-root user (worker). With USER worker we ensure that the application runs as an unprivileged user. The EXPOSE 9876 instruction makes the API available on port 9876, and the CMD sets the command to start the application.

Locally Build And Run The Docker Image

Let’s test the Docker image locally. Start by building the image using the following command:

docker build --tag restapipy:latest .

Assign a tag to the image using the --tag option in order to make it easier to reference later. The “.” at the end of the command specifies the current directory as the build context.

Once the image is built, you can run it using the following command:

$ docker run -p 8000:9876 restapipy:latest
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:9876 (Press CTRL+C to quit)

The -p 8000:9876 option maps port 8000 on your local machine to the container’s port 9876. Once the container is successfully running, you can test the API using curl or a browser:

curl http://127.0.0.1:8000/pythonic_breakfast

You should receive the following response:

"spam ham eggs"

Additional Resources On UV

While this tutorial covers some of the key features of uv for working with Docker, you might want to explore the following resources for more information:

Recap

In this post, we walked through the process of building a minimal, reproducible and production-ready Docker image for a FastAPI application using uv and a multistage Dockerfile. We covered how to structure your Python project for efficient builds, how to manage dependencies with uv sync, and how to isolate the runtime environment for security and performance. The result is a fast, lightweight image with a reduced attack surface, optimized for deployment in cloud or containerized environments.

The full source code is available on GitHub. Let us know what kind of content you’d like to see next. We look forward to your feedback and suggestions, you can reach out to us by opening an issue on the GitHub repository or by sending an email.