Docker for Python With uv: A Dockerfile That Caches Well

A practical Dockerfile for Python projects using uv: install from the lockfile, cache dependency layers, build in two stages, and avoid common mistakes.

Install uv from its official image, sync from the lockfile in its own layer, and copy only the virtual environment into the final image. That gives you small images and fast rebuilds. This guide follows the patterns in uv’s Docker documentation.

A working Dockerfile

This is a two-stage build for a typical application. The uv version is an example, so pin the current one you use.

# --- builder ---
FROM python:3.14-slim AS builder

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

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_NO_DEV=1

WORKDIR /app

# 1. Dependencies only: this layer is reused until the lockfile changes
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-install-project

# 2. Then the project itself
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-editable

# --- final ---
FROM python:3.14-slim

COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"

WORKDIR /app
CMD ["python", "-m", "myapp"]

Add .venv to .dockerignore so the environment on your machine never gets copied in.

What each part does

  • COPY --from=ghcr.io/astral-sh/uv:… takes the binaries from uv’s distroless image. Pin the version, and the uv docs also suggest pinning a SHA256 digest if you want fully reproducible builds.
  • uv sync --locked syncs the project into a new environment and fails if the lockfile is out of date. Use it for the final sync so a stale lockfile breaks the build rather than shipping something you didn’t test.
  • --frozen --no-install-project installs the dependencies from the lockfile without checking it and without installing your project. Because only the lockfile (and pyproject.toml) are bound into that step, Docker reuses the layer until dependencies change. Edit your source code and only the second step re-runs.
  • --mount=type=cache,target=/root/.cache/uv is a BuildKit cache mount that keeps uv’s download cache between builds.
  • UV_COMPILE_BYTECODE=1 compiles Python files to bytecode at install time. The uv docs recommend it for production: the install takes longer and the image is larger, but the app starts faster.
  • UV_LINK_MODE=copy silences the hardlink warning when uv’s cache and the target are on different filesystems, which is the case with a cache mount.
  • UV_NO_DEV=1 skips development dependencies.
  • --no-editable installs the project as a normal package, so the final image can run without the source tree. The uv docs describe the multi-stage pattern as copying the environment but not the source code.

Running a web app

For an ASGI app such as FastAPI, change the last line to start a server. Uvicorn is the usual choice; the Gunicorn vs Uvicorn vs Hypercorn guide covers when to put a process manager in front.

CMD ["uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8000"]

Bind to 0.0.0.0 inside a container. The default, 127.0.0.1, is unreachable from outside it.

Mistakes to avoid

  • Copying the whole project before installing dependencies. Any source change then invalidates the dependency layer and every rebuild reinstalls everything.
  • Skipping the lockfile. Without --locked or --frozen the image can resolve different versions from the ones you tested.
  • Leaving .venv out of .dockerignore. Your host’s virtual environment ends up in the build context and may end up in the image.
  • Shipping the builder stage. The final stage should start from a fresh base image and copy in only /app/.venv.
  • Running as root. uv’s documentation examples run as root and don’t cover creating a non-root user, so add one yourself in the final stage: it limits the damage if the app is compromised.
  • Forgetting to rebuild after a security advisory. An image keeps its old packages until it is rebuilt. See Python dependency security.

Choosing a Python base image

The examples use python:3.14-slim. Match the version to your lockfile and project, and check the supported Python versions before choosing one near its end of life. If you need to move up, Python 3.12 end of life covers what changes between versions.

If you’re new to uv itself, see uv vs venv vs conda and the uv resource page.

Frequently Asked Questions

How do I use uv in a Dockerfile?

Copy the uv binaries from its official image with COPY —from=ghcr.io/astral-sh/uv:<version> /uv /uvx /bin/, then install your dependencies with uv sync —locked. Pin the uv version so builds are reproducible.

What does uv sync —locked do in Docker?

It syncs the project into a new environment and fails if the lockfile is out of date, instead of silently updating it. That makes the image match the lockfile you committed.

How do I make Docker builds with uv faster?

Install dependencies in an earlier layer that only depends on the lockfile (uv sync —frozen —no-install-project), then copy the source and run the final sync. Add a BuildKit cache mount for /root/.cache/uv so downloads survive between builds.

Should I use a multi-stage build for Python?

Usually, yes. Build the virtual environment in a builder stage, then copy only the .venv into the final image. The final image doesn’t need uv or the build tools.

What is UV_COMPILE_BYTECODE?

Setting UV_COMPILE_BYTECODE=1 makes uv compile Python files to bytecode during installation. The uv documentation recommends it for production images: installs are slower and images larger, but the application starts faster.

Why add .venv to .dockerignore?

So the virtual environment on your machine is never copied into the image. It was built for your operating system and Python path, and it would also bloat the build context.