Deploy Django with uv for Reproducible Installs
Deploy Django reproducibly with uv: lockfiles, uv sync --locked without dev tools, managed Python, Docker layer caching, and CI checks.
Problem statement
A requirements.txt with loosely pinned packages installs different transitive dependency versions over time. The same commit deployed today and next month can behave differently, and a failed deploy caused by a new release of some indirect dependency is hard to trace.
uv, the Python package and project manager from Astral, solves this with a cross-platform lockfile (uv.lock) and very fast installs. Using it in deployment still requires a few decisions:
- whether the server or image installs exactly the lockfile, or silently re-resolves
- how development tools such as pytest stay out of production
- which Python interpreter runs the app, and who installs it
- whether production processes call
uv runor the virtualenv directly - how to keep Docker builds fast when only application code changed
This guide deploys a Django project managed with uv to a VM with systemd and to a Docker image, with CI checks that keep the lockfile honest.
Quick answer
- Manage dependencies in
pyproject.tomlwithuv add; commituv.lockand.python-version. - Put test and lint tools in a dev dependency group (
uv add --dev pytest). - In CI and deploys, install with
uv sync --locked --no-dev, which fails ifuv.lockis out of date instead of updating it. - On a VM, let uv create
.venvin the release directory and point systemd at.venv/bin/gunicorn. - In Docker, copy the uv binary from its official image, install dependencies in a cached layer with
--no-install-project, then copy the code and sync again. - Run production processes from the virtualenv directly, not through
uv run. - Verify installed versions match the lockfile.
Step-by-step solution
1. Project setup
In an existing project:
uv init --bare
uv python pin 3.13
uv add django gunicorn "psycopg[binary]" whitenoise
uv add --dev pytest pytest-django ruff
This produces:
pyproject.tomlwith runtime dependencies in[project.dependencies]and dev tools in[dependency-groups]uv.lockwith exact versions and hashes for every package, for all platforms.python-versioncontaining3.13
Commit all three. If you are migrating from requirements.txt, uv add -r requirements.txt imports the existing list.
2. The flags that matter in deployment
| Command | Behavior |
|---|---|
uv sync | updates uv.lock if pyproject.toml changed, then installs |
uv sync --locked | errors if uv.lock does not match pyproject.toml; never modifies it |
uv sync --frozen | installs from uv.lock without checking it against pyproject.toml |
--no-dev | skips the dev dependency group |
Use --locked in CI and production. A deploy should install what was reviewed and committed, and fail loudly if someone changed pyproject.toml without updating the lockfile.
3. Check the lockfile in CI
# GitHub Actions example
- uses: astral-sh/setup-uv@v6
- run: uv lock --check
- run: uv sync --locked
- run: uv run pytest
uv lock --check fails when the lockfile is stale. uv run is fine in CI, where convenience matters more than startup behavior.
4. Deploy to a VM with systemd
Install uv for the deploy user following the official installation instructions, then in each release directory:
cd /srv/myapp/releases/2026-09-17-abc1234
UV_PYTHON_DOWNLOADS=automatic uv sync --locked --no-dev
.venv/bin/python -c "import django; print(django.get_version())"
- uv creates
.venvinside the release directory, so each release has its own environment and rolling back is switching a symlink. - If the Python version from
.python-versionis not installed, uv downloads a managed interpreter. On servers where downloads are not allowed, install Python through the OS and setUV_PYTHON_DOWNLOADS=never. - uv caches downloaded packages (by default under the user's cache directory), so a new release with unchanged dependencies installs in seconds.
Run migrations and collect static files with the virtualenv's Python:
set -a; . /etc/myapp/env; set +a
.venv/bin/python manage.py migrate --noinput
.venv/bin/python manage.py collectstatic --noinput
ln -sfn /srv/myapp/releases/2026-09-17-abc1234 /srv/myapp/current
sudo systemctl restart myapp
The service points at the stable symlink:
[Service]
User=myapp
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/etc/myapp/env
ExecStart=/srv/myapp/current/.venv/bin/gunicorn config.wsgi:application \
--bind unix:/run/myapp/gunicorn.sock --workers 3
Managed Python interpreters live in uv's data directory for the user who ran uv sync. The service user must be able to read that interpreter, so either run uv sync as the service user or install Python system-wide.
5. Why not uv run in production?
uv run gunicorn ... checks that the environment matches the lockfile before starting and may sync it, which adds startup work, needs write access to the project directory, and can make network requests. In production the environment was already built during deploy. Call executables in .venv/bin/ directly, or set UV_NO_SYNC=1 if you must use uv run.
6. Docker build with layer caching
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.12.15 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
UV_NO_DEV=1
WORKDIR /app
# Dependencies only: cached until pyproject.toml or uv.lock change
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.13-slim
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=builder --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
USER app
RUN DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
Details:
- Pin the uv image tag (or digest) so builds do not change when uv releases a new version.
UV_PYTHON_DOWNLOADS=nevermakes uv use the image's Python, which must match.python-versioncompatibility.UV_COMPILE_BYTECODE=1compiles.pycfiles at build time, which speeds up container startup.UV_LINK_MODE=copyavoids hardlink warnings when the cache is a separate mount.- The cache mount requires BuildKit, which is the default builder in current Docker versions.
- The final stage contains the virtualenv and code but not uv itself.
- Add
.venvto.dockerignoreso a local environment is never copied into the build context.
7. Platforms that expect requirements.txt
Some platforms detect requirements.txt but not uv.lock. Export a pinned file from the lockfile in CI:
uv export --locked --no-dev --no-hashes --format requirements-txt > requirements.txt
Commit the export or generate it during the build. Keeping hashes (omit --no-hashes) adds integrity checking if the platform's pip supports --require-hashes.
8. Verify
# Installed versions of key packages match uv.lock
uv pip freeze --python .venv/bin/python | grep -i -E "^(django|gunicorn|psycopg)=="
grep -A1 -E '^name = "(django|gunicorn|psycopg)"$' uv.lock
# No dev tools in production
.venv/bin/python -c "import pytest" 2>&1 | tail -1
# Running Gunicorn uses the release's environment
ps -o pid,cmd -C gunicorn | head -3
Verification check:
uv sync --lockedsucceeds in CI and fails if you editpyproject.tomlwithout relocking- importing
pytestin production fails withModuleNotFoundError - Gunicorn processes run from the release's
.venv - a Docker rebuild after a code-only change reuses the dependency layer
Rollback
On a VM, point current back at the previous release directory, which contains its own .venv, and restart. In Docker, deploy the previous image tag. Neither requires reinstalling packages.
Explanation
Reproducible deploys need two guarantees: the dependency set is fully resolved ahead of time, and the deploy installs exactly that set. uv.lock provides the first; uv sync --locked --no-dev provides the second, failing instead of re-resolving when the files disagree. Per-release virtualenvs and multi-stage images then make each deploy self-contained, so upgrades and rollbacks never modify a running environment in place.
Edge cases / notes
Private package indexes
Configure indexes in pyproject.toml under [[tool.uv.index]] and pass credentials through environment variables in CI and Docker build secrets, never in the lockfile or image.
Platform-specific wheels
uv.lock is universal, covering multiple platforms. The deploy target still needs compatible wheels or build tools; test builds on the same OS and architecture as production.
Updating dependencies
Use uv lock --upgrade-package django for targeted upgrades and review the lockfile diff in a pull request, like any other code change.
Troubleshooting uv deployments
| Error or symptom | Cause | Fix |
|---|---|---|
The lockfile at uv.lock needs to be updated, but --locked was provided | pyproject.toml changed without relocking | run uv lock locally, commit uv.lock |
No interpreter found for Python 3.13 with downloads disabled | Python version from .python-version not installed | install it via the OS, or allow managed downloads |
ModuleNotFoundError for a dev tool in production | tool used at runtime but declared as dev dependency | move it to runtime dependencies with uv add |
Gunicorn cannot start: bad interpreter | .venv copied from another path or machine | recreate .venv in its final location; do not copy environments between hosts |
| Docker build reinstalls dependencies every time | COPY . /app placed before dependency sync, or lockfile changes | keep the dependency layer before copying the project |
| Build fails compiling a package | no wheel for the platform | install build dependencies in the builder stage or choose a version with wheels |
Why environments are not portable
Virtual environments contain absolute paths in script shebangs and configuration files. Copying .venv from a CI runner to a server, or moving a release directory after installing, breaks those paths. The Docker example avoids this by creating the environment at /app/.venv in the builder and copying it to the same path in the final stage.
Dependency groups beyond dev
You can define additional groups, for example for production-only tools:
[dependency-groups]
dev = ["pytest", "pytest-django", "ruff"]
prod = ["sentry-sdk"]
Install with uv sync --locked --no-dev --group prod. Most projects are simpler with runtime dependencies in [project.dependencies] and only development tools in groups.
Auditing dependencies
Export the locked runtime set and scan it for known vulnerabilities in CI:
uv export --locked --no-dev --format requirements-txt > /tmp/requirements.txt
uvx pip-audit -r /tmp/requirements.txt
uvx runs the tool in an isolated, cached environment without adding it to the project.
Migrating an existing server gradually
- Generate
pyproject.tomlanduv.lockfrom the currentrequirements.txtand deploy them unused. - In the next release, build the release environment with
uv sync --locked --no-devnext to the old virtualenv. - Point the systemd unit at the new
.venv, restart, and verify. - Remove the old virtualenv and
requirements.txtworkflow once a few releases have succeeded.
Next steps
- Build lean images with Build Smaller, Faster Django Docker Images.
- Automate upgrades with Automate Django Dependency and Security Updates.
- Debug import failures with Fix ModuleNotFoundError When Starting Django with Gunicorn.
FAQ
Should I use uv sync --locked or --frozen when deploying Django?
Use --locked. It installs from uv.lock and fails if the lockfile does not match pyproject.toml. --frozen skips that check, which is mainly useful in special build layers where pyproject.toml files are not all available.
How do I keep pytest and other dev tools out of production?
Add them with uv add --dev, which puts them in the dev dependency group, and install production environments with --no-dev or UV_NO_DEV=1.
Can I use uv without Docker on a normal server?
Yes. Install uv, run uv sync --locked --no-dev in the release directory, and point systemd at the .venv/bin/gunicorn executable.
Should Gunicorn be started with uv run in production?
Prefer calling .venv/bin/gunicorn directly. uv run may check or sync the environment at startup, which production processes do not need.