Running uv-Based Docker Containers in Read-Only Mode
I recently wanted to make a Python application (with FastAPI and uv) read-only when running in a Docker container. This was a security requirement from an enterprise customer. It should have been simple, but turned out to be quite frustrating, since the container kept crashing with cryptic errors about missing modules that were definitely installed during the build.
In this post, I’ll share the solution I found to run uv-based Python applications in read-only Docker containers, minimizing filesystem writes while ensuring everything works smoothly.
Python Bytecode Files and uv Caches
The core problem is that Python really wants to write to the filesystem for caching purposes. Every time you import a module, Python tries to create bytecode cache files in __pycache__ directories. When you’re using uv (the new package manager everyone’s raving about, rightfully so!), it also wants to write cache metadata to ~/.cache/uv/.
I initially thought I could just mount tmpfs volumes over these directories. This is how you would usually enable read-only mode for a typical Python app, by specifying something like this in your docker-compose.yml:
read_only: true
tmpfs:
- /home/appuser/.cache:uid=1001,gid=1001 # -> these are paths your app needs to write to
Note: 1001 is the UID of my non-root user. You should always add a non-root user to execute your actual application code in Docker!
This didn’t work, because there was another problem: I had a Git dependency that used SSH to clone a repository during uv package installation, and, despite installing the packages already in the Dockerfile, uv still tried to install the packages again at runtime when I called my entrypoint script with uv run .... This is because uv defaults to checking for updates and re-installing packages unless you tell it not to.
Solution
The trick is understanding that:
- You don’t actually need Python to write bytecode files at runtime if you’re smart about the build process. uv has some flags that solve this elegantly.
- You can disable uv’s default behavior of checking for updates and re-installing packages at runtime.
First, tell Python to stop trying to create bytecode files altogether by setting PYTHONDONTWRITEBYTECODE=1. This environment variable is your friend – it prevents Python from creating any .pyc files during execution. You can still get the performance benefits of bytecode compilation by using uv’s --compile-bytecode flag during the build. This pre-compiles everything when you’re building the image, so there’s no need for runtime compilation.
Your Dockerfile should look something like this:
# Install dependencies and pre-compile bytecode
COPY ./pyproject.toml ./uv.lock ./
RUN uv sync --locked --no-editable --compile-bytecode --no-dev && \
chown -R ${UID}:${GID} /home/${USER}/.venv
The --locked flag ensures uv uses exactly what’s in your lockfile instead of trying to resolve dependencies again. The --no-editable flag installs packages normally – rather than in development mode (which would require write access). And --compile-bytecode does all the bytecode compilation upfront.
And as for running the application, use uv run with the --no-sync flag to prevent uv from trying to re-install or update packages at runtime:
uv run --no-sync uvicorn app.main:app --host 0.0.0.0 --port 80
Minimal Filesystem Writes
With the bytecode issue sorted, you only need tmpfs mounts for the bare minimum – just the uv cache directory for metadata. Your docker-compose.yml should look like this:
read_only: true
tmpfs:
- /home/appuser/.cache:uid=1001,gid=1001
Complete Example
Here’s a complete working example:
Dockerfile:
FROM python:3.13-slim
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
# Create non-root user
ENV USER=appuser
ENV UID=1001
ENV GID=1001
RUN groupadd --gid ${GID} ${USER} && \
useradd --system --create-home --home-dir /home/${USER} \
--shell /bin/bash --gid ${GID} --uid ${UID} ${USER}
WORKDIR /home/${USER}
# Install dependencies
COPY ./pyproject.toml ./uv.lock ./
RUN uv sync --locked --no-editable --compile-bytecode --no-dev && \
chown -R ${UID}:${GID} /home/${USER}/.venv
# Switch to non-root user
USER ${USER}
# Copy application code
COPY --chown=${UID}:${GID} ./app ./app
# Prevent bytecode generation at runtime
ENV PYTHONDONTWRITEBYTECODE=1
ENTRYPOINT ["app/entrypoint.sh"]
docker-compose.yml:
services:
app:
image: my-app
read_only: true
tmpfs:
- /home/appuser/.cache:uid=1001,gid=1001
ports:
- "8000:80"
cap_drop:
- ALL
entrypoint.sh:
#!/bin/bash
# Use uv run to execute with the pre-installed environment
uv run --no-sync uvicorn app.main:app --host 0.0.0.0 --port 80
Verification
To verify your container is working correctly in read-only mode:
# Check for filesystem changes
docker diff <container-name>
# Should show minimal output like:
# C /home/appuser
# A /home/appuser/.cache
The important insight is that uv’s --locked --no-editable --compile-bytecode flags at build time, combined with PYTHONDONTWRITEBYTECODE=1 at runtime, eliminate most filesystem write requirements.
Security Benefits
Running in read-only mode provides several security advantages:
- Prevents privilege escalation: Malicious code can’t modify system files
- Limits attack surface: Reduces potential for persistent modifications
- Compliance: Meets security requirements for many organizations
- Immutable infrastructure: Ensures containers remain in known good state
I verified this works with FastAPI, but it should work for any other Python application as well.