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 run or 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.

Reproducible installs with uvpyproject.tomldeclared depsuv.lockexact versionsuv sync --lockedbuilds .venvGunicorn.venv/binThe lockfile is the contract; --locked fails instead of silently re-resolving.

Quick answer

  1. Manage dependencies in pyproject.toml with uv add; commit uv.lock and .python-version.
  2. Put test and lint tools in a dev dependency group (uv add --dev pytest).
  3. In CI and deploys, install with uv sync --locked --no-dev, which fails if uv.lock is out of date instead of updating it.
  4. On a VM, let uv create .venv in the release directory and point systemd at .venv/bin/gunicorn.
  5. 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.
  6. Run production processes from the virtualenv directly, not through uv run.
  7. 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.toml with runtime dependencies in [project.dependencies] and dev tools in [dependency-groups]
  • uv.lock with exact versions and hashes for every package, for all platforms
  • .python-version containing 3.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

CommandBehavior
uv syncupdates uv.lock if pyproject.toml changed, then installs
uv sync --lockederrors if uv.lock does not match pyproject.toml; never modifies it
uv sync --frozeninstalls from uv.lock without checking it against pyproject.toml
--no-devskips the dev dependency group
uv sync flags for productionEffect--lockedfail if uv.lock is out of date--frozenuse uv.lock as is, never resolve--no-dev✓skip dev dependency groups--no-editableinstall the project as a normal packageUV_COMPILE_BYTECODE=1compile .pyc files at install timeCI uses --locked to catch drift; deploys use --frozen to install exactlydjango-deployment.com
Compiled bytecode shortens the first request after each restart.

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())"
a uv release on a VM1Unpack the releasecode, pyproject.toml, uv.lock2uv sync --frozen --no-devcreates .venv in the release3migrate, collectstaticvia .venv/bin/python4Switch current, restartunit runs .venv/bin/gunicorn5Verifyhealth URL, journaluv is needed at deploy time only; the service never calls uv rundjango-deployment.com
A per-release .venv makes rollback include the dependencies too.
  • uv creates .venv inside the release directory, so each release has its own environment and rolling back is switching a symlink.
  • If the Python version from .python-version is not installed, uv downloads a managed interpreter. On servers where downloads are not allowed, install Python through the OS and set UV_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"]
dependencies cached, code rebuiltCOPY uv.lock pyproject.tomlchanges rarelyuv sync --frozen --no-install-projectcached until the lock changesCOPY . .changes every commituv sync --frozeninstalls only the projectA code-only change reuses the dependency layer entirelydjango-deployment.com
A cache mount for ~/.cache/uv speeds up even the uncached case.

Details:

  • Pin the uv image tag (or digest) so builds do not change when uv releases a new version.
  • UV_PYTHON_DOWNLOADS=never makes uv use the image's Python, which must match .python-version compatibility.
  • UV_COMPILE_BYTECODE=1 compiles .pyc files at build time, which speeds up container startup.
  • UV_LINK_MODE=copy avoids 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 .venv to .dockerignore so 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 --locked succeeds in CI and fails if you edit pyproject.toml without relocking
  • importing pytest in production fails with ModuleNotFoundError
  • 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 symptomCauseFix
The lockfile at uv.lock needs to be updated, but --locked was providedpyproject.toml changed without relockingrun uv lock locally, commit uv.lock
No interpreter found for Python 3.13 with downloads disabledPython version from .python-version not installedinstall it via the OS, or allow managed downloads
ModuleNotFoundError for a dev tool in productiontool used at runtime but declared as dev dependencymove it to runtime dependencies with uv add
Gunicorn cannot start: bad interpreter.venv copied from another path or machinerecreate .venv in its final location; do not copy environments between hosts
Docker build reinstalls dependencies every timeCOPY . /app placed before dependency sync, or lockfile changeskeep the dependency layer before copying the project
Build fails compiling a packageno wheel for the platforminstall build dependencies in the builder stage or choose a version with wheels
uv deploy problems and causesSymptomUsual causelockfile needs to be updatedpyproject changed without uv lockgunicorn: not foundunit uses a PATH without .venv/binBroken venv after copyingvenvs hold absolutepaths; rebuild in placeDifferent versions than localdeploy ran without --frozenAlmost every uv problem is a lock or path mismatchdjango-deployment.com
Use full paths to .venv/bin in systemd units rather than relying on PATH.

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

  1. Generate pyproject.toml and uv.lock from the current requirements.txt and deploy them unused.
  2. In the next release, build the release environment with uv sync --locked --no-dev next to the old virtualenv.
  3. Point the systemd unit at the new .venv, restart, and verify.
  4. Remove the old virtualenv and requirements.txt workflow once a few releases have succeeded.

Next steps

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.

Keep going