# Build a production Docker image for a uv project


A default Dockerfile for a Python project can produce a 306 MB image for a two-endpoint API. This tutorial starts with a working [uv](https://pydevtools.com/handbook/reference/uv.md) project, writes a basic Dockerfile, then applies four optimizations that cut that image to 223 MB and make rebuilds finish in seconds. By the end, you will have a production-ready container for a FastAPI application with only the files it needs to run.

## Prerequisites

You need two tools installed before starting:

- **uv**: follow the [installation guide](https://pydevtools.com/handbook/how-to/how-to-install-uv.md)
- **Docker**: install [Docker Desktop](https://docs.docker.com/get-started/get-docker/) (macOS and Windows) or [Docker Engine](https://docs.docker.com/engine/install/) (Linux)

Verify both are installed by running `uv --version` and `docker --version`.

## Create a FastAPI project

Initialize a new uv project and add [FastAPI](https://fastapi.tiangolo.com/) with [uvicorn](https://www.uvicorn.org/):

```console
$ uv init --python 3.13 myapi
Initialized project `myapi` at `/path/to/myapi`
$ cd myapi
```

`uv init` creates a Python package with a `src/` layout by default. If it creates a flat layout with `main.py` instead, run `uv init --package myapi` to get the package structure this tutorial uses.

```console
$ uv add fastapi uvicorn
Resolved 15 packages in 69ms
Installed 14 packages in 24ms
 + fastapi==0.115.12
 + uvicorn==0.35.0
 ...
```

`uv add` installs the packages and records them in [pyproject.toml](https://pydevtools.com/handbook/reference/pyproject.toml.md) and the {{< term "lockfile" >}}. The project now looks like this:

{{< /filetree/folder >}}
    {{< /filetree/folder >}}
  {{< /filetree/folder >}}
{{< /filetree/container >}}

## Write the application code

Create `src/myapi/app.py` with a FastAPI application:

```python {filename="src/myapi/app.py"}
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def index():
    return {"message": "Hello from uv + Docker"}


@app.get("/health")
def health():
    return {"status": "ok"}
```

## Verify the app runs locally

Start the server with `uv run`. `uv run` executes the command inside the project's {{< term "virtual-environment" "virtual environment" >}}, ensuring all dependencies are installed first.

```console
$ uv run uvicorn myapi.app:app --host 127.0.0.1 --port 8000
INFO:     Started server process [12345]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
```

In a separate terminal, confirm the endpoint responds, then stop the server with `Ctrl+C`:

```console
$ curl http://127.0.0.1:8000
{"message":"Hello from uv + Docker"}
```

## Write your first Dockerfile

Create a `Dockerfile` in the project root:

```dockerfile {filename="Dockerfile"}
FROM python:3.13-slim

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app
COPY . .
RUN uv sync --frozen

CMD ["uv", "run", "uvicorn", "myapi.app:app", "--host", "0.0.0.0", "--port", "8000"]
```

The `COPY --from` line pulls the uv binary from the [official uv container image](https://github.com/astral-sh/uv/pkgs/container/uv). `uv sync --frozen` installs exactly what `uv.lock` specifies without checking whether the lockfile needs updating.

## Build and run the container

Build the image, then run it with port 8000 mapped to your host:

```console
$ docker build -t myapi:naive .
$ docker run -p 8000:8000 myapi:naive
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
```

In another terminal, test the health endpoint:

```console
$ curl http://localhost:8000/health
{"status":"ok"}
```

Stop the container with `Ctrl+C` and check the image size:

```console
$ docker images myapi:naive
REPOSITORY   TAG     IMAGE ID       CREATED          SIZE
myapi        naive   a1b2c3d4e5f6   30 seconds ago   306MB
```

306 MB for a two-endpoint API. The image includes the uv binary (~48 MB), the local `.venv` (copied by `COPY . .`), `.git` history, and `__pycache__` files.

## Exclude unnecessary files with .dockerignore

`COPY . .` sends everything in the project directory to Docker, including the local {{< term "virtual-environment" "virtual environment" >}} and `.git` history. A `.dockerignore` file excludes them:

```text {filename=".dockerignore"}
.venv
.git
__pycache__
*.pyc
.ruff_cache
.mypy_cache
```

Rebuild and compare:

```console
$ docker build -t myapi:ignore .
$ docker images myapi:ignore
REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
myapi        ignore   b2c3d4e5f6a7   15 seconds ago   286MB
```

Down from 306 MB to 286 MB. The `.venv` exclusion accounts for most of the savings.

## Split dependency and source layers

The current Dockerfile reinstalls every dependency whenever any source file changes because `COPY . .` puts everything in one layer. Splitting the install into two steps lets Docker reuse the dependency layer when only source code changes:

```dockerfile {filename="Dockerfile"}
FROM python:3.13-slim

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

COPY . .
RUN uv sync --frozen

CMD ["uv", "run", "uvicorn", "myapi.app:app", "--host", "0.0.0.0", "--port", "8000"]
```

`--no-install-project` installs all dependencies without the project package itself; the second `uv sync` installs the project after copying source code. This split does not change the image size (still 286 MB), but it changes rebuild speed. Edit the message string in `app.py` and rebuild: Docker reuses the cached dependency layer, finishing in about two seconds.

## Remove uv from the final image with a multi-stage build

The image still contains uv and the full project source. A multi-stage build separates the builder from the runtime:

```dockerfile {filename="Dockerfile"}
# -- Builder stage --
FROM python:3.13-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

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

COPY . .
RUN uv sync --frozen --no-dev --no-editable

# -- Runtime stage --
FROM python:3.13-slim

WORKDIR /app
COPY --from=builder /app/.venv /app/.venv

ENV PATH="/app/.venv/bin:$PATH"

CMD ["uvicorn", "myapi.app:app", "--host", "0.0.0.0", "--port", "8000"]
```

Two new flags appear in the builder stage:

- `--no-dev` excludes {{< term "dev-dependency" "development dependencies" >}} from the production image.
- `--no-editable` installs the project as a regular package inside `.venv` instead of as an editable install, making the {{< term "virtual-environment" "virtual environment" >}} self-contained with both dependencies and project code.

The runtime stage starts fresh from `python:3.13-slim`, copies only the `.venv` from the builder, and adds its `bin/` directory to `PATH`. The `CMD` runs `uvicorn` directly because uv is not present in this stage.

Build and verify:

```console
$ docker build -t myapi:multi .
$ docker run -p 8000:8000 myapi:multi
```

The container starts the same way. Confirm the endpoint still responds:

```console
$ curl http://localhost:8000
{"message":"Hello from uv + Docker"}
```

Stop the container with `Ctrl+C` and check the size:

```console
$ docker images myapi:multi
REPOSITORY   TAG     IMAGE ID       CREATED          SIZE
myapi        multi   c3d4e5f6a7b8   10 seconds ago   215MB
```

Down from 286 MB to 215 MB: uv, project source, and build caches are gone from the final image.

## Compile bytecode for faster startup

Python compiles `.py` files to `.pyc` bytecode on first import. In a container, every cold start pays that cost. `UV_COMPILE_BYTECODE=1` shifts compilation to the image build so containers start faster. `UV_LINK_MODE=copy` tells uv to copy files instead of hard-linking them, avoiding warnings from Docker's overlay filesystem.

Add both variables to the builder stage:

```dockerfile {filename="Dockerfile"}
# -- Builder stage --
FROM python:3.13-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy

WORKDIR /app

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

COPY . .
RUN uv sync --frozen --no-dev --no-editable

# -- Runtime stage --
FROM python:3.13-slim

WORKDIR /app
COPY --from=builder /app/.venv /app/.venv

ENV PATH="/app/.venv/bin:$PATH"

CMD ["uvicorn", "myapi.app:app", "--host", "0.0.0.0", "--port", "8000"]
```

This is the final Dockerfile. Build it:

```console
$ docker build -t myapi:prod .
$ docker images myapi:prod
REPOSITORY   TAG    IMAGE ID       CREATED          SIZE
myapi        prod   d4e5f6a7b8c9   10 seconds ago   223MB
```

The image is 8 MB larger than without bytecode (215 MB vs. 223 MB) because the `.pyc` files are included. Every container start skips the compilation step, which matters for large dependency trees and platforms that launch containers frequently (serverless, Kubernetes, job runners).

Run the final image and verify the endpoint:

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

```console
$ curl http://localhost:8000
{"message":"Hello from uv + Docker"}
```

## Compare image sizes

| Dockerfile variant | Image size | What changed |
|---|---|---|
| Single-stage, no `.dockerignore` | 306 MB | Baseline: includes `.venv`, `.git`, uv |
| Added `.dockerignore` | 286 MB | Excluded `.venv`, `.git`, `__pycache__` from build context |
| Split dependency layers | 286 MB | Same size, but rebuilds skip dependency install when only source changes |
| Multi-stage build | 215 MB | Dropped uv (~48 MB), source code, and build caches from the final image |
| Multi-stage + bytecode compilation | 223 MB | Added `.pyc` files (+8 MB) for faster cold starts |

The biggest single improvement came from the multi-stage build, which cut 71 MB by keeping build-time tools out of the runtime image.

> [!TIP]
> For reproducible builds, pin the uv version instead of using `latest`. Replace `ghcr.io/astral-sh/uv:latest` with a specific tag like `ghcr.io/astral-sh/uv:0.12.4`. The [How to use uv in a Dockerfile](https://pydevtools.com/handbook/how-to/how-to-use-uv-in-a-dockerfile.md) guide covers version pinning, cache mounts, and private index configuration.

## Learn More

- [uv Docker integration guide](https://docs.astral.sh/uv/guides/integration/docker/) documents every environment variable and Dockerfile pattern for uv in containers
- [Production-ready Python Docker containers with uv](https://hynek.me/articles/docker-uv/) covers advanced patterns including `UV_PROJECT_ENVIRONMENT` and non-root users
- [How to use uv in a Dockerfile](https://pydevtools.com/handbook/how-to/how-to-use-uv-in-a-dockerfile.md) covers version pinning, cache mounts, private index configuration, and base image selection
- [uv: A Complete Guide](https://pydevtools.com/handbook/explanation/uv-complete-guide.md) covers uv's core workflows beyond Docker
