
> Build Multistage Python Docker Images Using UV
Reduce image size and build time with a multistage Dockerfile and uv
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:
- uv documentation: Using uv in Docker
- uv documentation: CLI Reference
- Should you use uv’s managed Python in production? - Blog post by Itamar Turner-Trauring
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.