A Permission denied error under /usr/local/lib/python3.12/site-packages usually means uv is trying to install into a Python environment that the current container user cannot modify. Setting UV_SYSTEM_PYTHON=1 changes environment selection; it does not grant filesystem permissions. For an application image, install dependencies during the build and run the finished environment as an unprivileged user.
This distinction matters when migrating from pip. uv does not implement pip’s --user installation scheme or its fallback to a user installation when the target directory is unwritable. Its documentation recommends virtual environments instead. Adding --user to the failing command is therefore not the fix. uv’s compatibility guide
Find the path that failed
Start with the complete error, including the destination path. A cache failure and a package installation failure need different changes:
| Failing destination | What to check | Appropriate change |
|---|---|---|
/usr/local/lib/python…/site-packages |
System Python selected after USER changed |
Install at build time or target a writable virtual environment |
/opt/venv/lib/python…/site-packages |
Existing environment ownership | Rebuild dependencies, or explicitly provide a user-owned environment for a development workflow |
| A directory beneath the uv cache | Cache location and its owner | Choose a writable UV_CACHE_DIR; keep the installation target separate |
| A managed Python installation directory | Interpreter download location | Install Python during the build, or configure a writable UV_PYTHON_INSTALL_DIR |
The last two variables address separate storage locations. Neither makes site-packages writable. UV_PYTHON_DOWNLOADS=never prevents an automatic Python download when the image already provides the intended interpreter. These controls are documented in uv’s environment variable reference.
Inspect the actual process identity and interpreter inside the failing container:
id
python -c 'import sys, sysconfig; print(sys.executable); print(sys.prefix); print(sysconfig.get_path("purelib"))'
uv cache dir
Compare that interpreter with the one passed to uv. An explicit uv pip install --python /opt/venv/bin/python … removes ambiguity. --system deliberately bypasses virtual environments, so adding it to a command intended for /opt/venv moves in the wrong direction. uv environment selection
Build dependencies before switching users
Here is a small, complete example using the pip-compatible interface. It pins uv, Python, and a dependency so the example has a clear version boundary. These are reproducible example versions, not a claim that they are the newest releases. Select maintained versions for your deployment and rerun the checks when updating them.
Save this as Dockerfile:
FROM python:3.12.12-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:0.9.21 /uv /usr/local/bin/uv
ENV UV_PYTHON_DOWNLOADS=never \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH"
RUN uv venv --python /usr/local/bin/python /opt/venv \
&& uv pip install --no-cache --python /opt/venv/bin/python idna==3.10
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --create-home app
WORKDIR /app
COPY app.py /app/app.py
USER 10001:10001
CMD ["/opt/venv/bin/python", "/app/app.py"]
Save the following as app.py:
import os
import sys
import idna
assert os.getuid() == 10001
assert sys.prefix == "/opt/venv"
assert not os.access("/opt/venv/lib/python3.12/site-packages", os.W_OK)
print(f"uid={os.getuid()} prefix={sys.prefix} idna={idna.__version__}")
print(idna.encode("münich.example").decode())
The environment belongs to root because the installation runs before USER. The application can read and execute its dependencies without being able to replace them. Docker’s USER instruction changes subsequent RUN commands and the runtime identity; moving it above the dependency installation changes who needs write access. Dockerfile USER reference
The absolute interpreter in CMD also makes startup independent of shell activation. There is no installation command on the startup path. uv’s Docker guide documents copying its binary from an image and running from a virtual environment. For stricter build reproducibility, replace mutable image tags with verified image digests.
Verify both execution and permissions
Build and run the example:
docker build -t uv-permissions-demo .
docker run --rm --network=none --read-only uv-permissions-demo
This example was built and run on Linux ARM64 with uv 0.9.21 and Python 3.12.12. Other architectures were not tested. The expected output is:
uid=10001 prefix=/opt/venv idna=3.10
xn--mnich-kva.example
The assertions check three independent properties: the process is unprivileged, it uses the intended environment, and its package directory is not writable. Running without a network and with a read-only root filesystem also checks that this tiny application’s startup does not depend on downloading or modifying packages. A real application may need an explicitly writable directory for uploads, state, or temporary files; test those requirements separately.
To exercise the failure boundary, run a disposable container with the system installation command:
docker run --rm uv-permissions-demo \
uv pip install --system --no-cache idna==3.10
This command requires package-index access and should fail while writing to system site-packages. It does not target the already populated virtual environment. The error confirms why changing the Python selection alone cannot solve the permission problem.
When installation really must happen as non-root
Development containers sometimes need dependency changes after startup. In that case, deliberately create an environment owned by the development user. For example, create /home/app/venv while running as app, then install with --python /home/app/venv/bin/python. Ensure the home directory and cache are writable too.
Avoid applying recursive ownership changes to /usr/local just to make an installation pass. Give the specific environment the ownership required by its workflow. A read-only root filesystem additionally needs an appropriately owned writable mount for any environment that will change at runtime; changing ownership inside the image cannot make a read-only mount writable.
For an existing uv project, use its committed lockfile and install during the build with uv sync --locked --no-dev. Start the installed application’s interpreter or entry point directly. uv run normally checks and synchronizes the environment before execution; using it at startup can introduce writes that an immutable application image was not designed to permit. Locking and syncing
The original non-root installation report concerned uv 0.6.16 and Python 3.12.10 in 2025. It is useful evidence of the migration trap, not proof of a new uv regression. Diagnose today’s failure by its path and process identity: the user running an installation needs write access to that installation’s destination, while the user running the finished application usually needs only read and execute access.
Sources are linked throughout this guide. Product capabilities can change; consult the linked documentation for your deployment.
Read our editorial approach ↗