In a Dockerfile, run uv sync with the locked flag so the build fails if the lockfile is stale, set UV_NO_DEV so development packages stay out, and mount the uv cache instead of baking it into a layer. Copy the finished environment into a clean final stage.
Why a locked install matters
A build that resolves versions fresh each time can pull something different from what you tested. The uv documentation describes two related flags. The locked option asserts that the lockfile is up to date and errors if it is not. The frozen option uses the lockfile as it is, without checking that it matches the project file.
For production images, locked is the stricter choice, because a mismatch between pyproject and lockfile becomes a build failure, not a silent change.
A layered Dockerfile
The uv Docker guide recommends installing dependencies in one step and the project in another, so that code changes do not invalidate the dependency layer. A sketch following that approach:
FROM python:3.14-slim AS build
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
ENV UV_NO_DEV=1 UV_LINK_MODE=copy UV_COMPILE_BYTECODE=1
WORKDIR /app
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 --locked --no-install-project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
FROM python:3.14-slim
COPY --from=build /app /app
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
USER 1000
CMD ["python", "-m", "myapp"]
In real use, pin the uv image by version or digest instead of latest, as with any base image. The uv docs also say that for multi-stage images, the no-editable flag can be used so the project itself is installed into the environment rather than linked to source that will not exist in the final stage.
What each setting does
| Setting | Documented purpose |
|---|---|
| UV_NO_DEV | Leave development dependency groups out of the environment |
| UV_COMPILE_BYTECODE | Compile bytecode at install time so it is baked in |
| UV_LINK_MODE=copy | Silence link warnings when cache and target are on different filesystems |
| Cache mount | Keep downloads between builds without adding them to the image |
| No cache flag or UV_NO_CACHE | Smaller image when you are not mounting a cache |
Things that go wrong
The final stage must have the same Python as the build stage, since the virtual environment points at an interpreter path. The uv guide discusses managed Python installs and a setting to disable downloads, so the image uses the interpreter you chose on purpose.
Do not mount secrets for private indexes into a layer. Use a build secret mount instead, and review how your index credentials are passed, as covered in Docker build secrets without leaking into layers.
Try it yourself
Open the dependency inventory demo. Compare the direct dependencies from your project file against the full transitive set a lockfile pins. A locked install gives you exactly that set every time.
Click through it at your own pace, or open it full screen.
Where Safeguard fits
A lockfile is the input Safeguard dependency scanning and SBOM generation read. Policies and gates can then block a pull request that adds a package you do not allow. Run a free scan on the repository.
Quick check
What is the difference between locked and frozen in uv sync?
Locked requires the lockfile to be up to date with the project file and fails if not. Frozen uses the lockfile without that check. Prefer locked in CI and production builds.
Does a cache mount end up in the final image?
No. A cache mount is available during the RUN step but is not stored in the image layers.
Sources
Last reviewed: 19 October 2026.