Deploy Django with Poetry

Deploy a Poetry-managed Django project: lock file checks, installing only main dependencies, in-project virtualenvs, multi-stage Docker builds without Poetry at runtime, and exporting requirements.

Problem statement

Poetry manages a Django project's dependencies in pyproject.toml and pins exact versions in poetry.lock. In development that works smoothly. In deployment, teams run into a predictable set of problems: the server installs different versions because the lock file is stale or ignored, development tools like pytest end up in production, Poetry creates its virtual environment somewhere Gunicorn's systemd unit does not look, or the Docker image carries Poetry and its caches for no reason.

This guide deploys a Poetry project reproducibly to a server and to a container image, with Poetry used only where it is needed.

Poetry in a Django releasepyproject.tomldeclared rangespoetry.lockexact versionsInstall main onlyno dev tools.venvused by GunicornProduction installs exactly what the lock file records, and only the main dependency group.
  1. Commit poetry.lock and fail CI when it is out of date with poetry check --lock.
  2. Put development tools in a separate group ([tool.poetry.group.dev.dependencies]).
  3. For an application that is not a package, set package-mode = false, or install with --no-root.
  4. On servers, set virtualenvs.in-project = true and install with poetry install --only main --no-interaction.
  5. Point systemd at .venv/bin/gunicorn in the project directory.
  6. In Docker, use Poetry in a build stage and copy only the virtual environment into the runtime stage.

Step-by-step solution

1. Structure pyproject.toml

[project]
name = "myapp"
version = "1.4.0"
requires-python = ">=3.12,<3.14"
dependencies = [
    "django>=5.2,<6.0",
    "gunicorn>=23",
    "psycopg[binary]>=3.2",
    "whitenoise>=6.7",
]

[tool.poetry]
package-mode = false

[tool.poetry.group.dev.dependencies]
pytest-django = "^4.9"
django-debug-toolbar = "^5.0"
ruff = "^0.6"

Poetry 2 reads the standard [project] table; older projects declare dependencies under [tool.poetry.dependencies], which still works. package-mode = false tells Poetry this is an application: it manages dependencies but does not try to build or install the project itself.

2. Keep the lock file honest

poetry lock          # after editing dependencies
poetry check --lock  # in CI: fails if poetry.lock does not match pyproject.toml

Commit poetry.lock. Without it, each install resolves versions again and production can end up with a release you never tested. On older Poetry versions, the check command was poetry lock --check.

3. Install on a server

Install Poetry itself outside the project environment, with pipx or the official installer, and pin its version so behaviour does not change unexpectedly:

pipx install "poetry==2.1.3"

Configure the project to create its virtual environment inside the project directory:

cd /srv/myapp/current
poetry config virtualenvs.in-project true --local
poetry install --only main --no-interaction --no-ansi
ls .venv/bin/gunicorn
the server install, flag by flagpipx install "poetry==2.1.3"poetry configvirtualenvs.in-projecttrue --localpoetry install--only main--no-interaction--no-ansi12341Pin Poetry itself; lock formats change between versions.2The venv lands in .venv, a path systemd can use.3No test or dev dependencies on production.4Never wait for a prompt in a deploy script.Gunicorn then runs as .venv/bin/gunicorn, with no poetry run wrapperdjango-deployment.com
poetry sync removes packages that are no longer in the lock file.

--local writes the setting to poetry.toml in the project, so every environment behaves the same. Commit poetry.toml if you want that across machines. --only main excludes the dev group. If package-mode is not set, add --no-root for applications.

To remove packages that are no longer in the lock file from an existing environment, use poetry sync --only main (Poetry 2) or poetry install --sync (Poetry 1.x). A fresh virtual environment per release avoids the question entirely.

4. Run Django from the environment

# /etc/systemd/system/gunicorn.service (excerpt)
[Service]
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/etc/myapp.env
ExecStart=/srv/myapp/current/.venv/bin/gunicorn config.wsgi:application --bind unix:/run/gunicorn/gunicorn.sock --workers 3

Management commands in deploy scripts use the same interpreter:

.venv/bin/python manage.py migrate --noinput
.venv/bin/python manage.py collectstatic --noinput

poetry run works too, but calling .venv/bin/python directly avoids depending on Poetry at runtime and on the deploy user's shell configuration.

5. Docker: Poetry in the build stage only

FROM python:3.12-slim AS build
ENV POETRY_VERSION=2.1.3 \
    POETRY_VIRTUALENVS_IN_PROJECT=true \
    POETRY_NO_INTERACTION=1 \
    POETRY_CACHE_DIR=/tmp/poetry-cache
RUN pip install --no-cache-dir "poetry==${POETRY_VERSION}"
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry install --only main --no-root && rm -rf "$POETRY_CACHE_DIR"

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY --from=build /app/.venv /app/.venv
COPY . .
RUN DJANGO_SETTINGS_MODULE=config.settings.build python manage.py collectstatic --noinput
USER nobody
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
Poetry builds, the runtime only runsBuild stagePoetry, compilers, lock filepoetry install --only maininto /app/.venvRuntime stageslim Python, no PoetryCOPY --from=build /app/.venvonly the environmentThe runtime image is smaller and has fewer tools an attacker could usedjango-deployment.com
Both stages must use the same Python version, or the copied venv breaks.
  • Copying pyproject.toml and poetry.lock before the source code lets Docker cache the dependency layer until dependencies change.
  • The runtime stage contains the virtual environment but not Poetry or its cache.
  • Both stages must use the same base image and Python version, because the virtual environment contains absolute paths and compiled extensions for that interpreter.
  • Add .venv to .dockerignore so a local environment is never copied over the built one.

6. Alternative: export to requirements

Some platforms install from requirements.txt only. Export with hashes using the export plugin (a separate plugin since Poetry 2):

poetry self add poetry-plugin-export
poetry export --only main -f requirements.txt -o requirements.txt
three ways to use the lock filePoetry on production?Suitspoetry install on server!yesVMs you already managePoetry in build stage✓noDocker imagespoetry export → pip✓noPaaS expecting requirements.txtAll three install exactly the locked versionsdjango-deployment.com
Exported files need the export plugin in Poetry 2 and should be regenerated in CI.

Generate the file in CI from the lock file, or commit it and check in CI that it matches, so the two do not drift.

Verification check:

poetry check --lock
.venv/bin/python -c "import django; print(django.get_version())"
.venv/bin/pip list | grep -iE "pytest|debug-toolbar" || echo "no dev tools installed"

Explanation

Poetry separates intent (pyproject.toml ranges) from resolution (poetry.lock exact versions and hashes). Deploying reproducibly means installing from the lock file only, excluding groups production does not need, and knowing exactly where the resulting environment lives. After installation, the environment is an ordinary virtualenv; Gunicorn, systemd, and Django do not know or care that Poetry built it.

Edge cases / notes

Private package sources

Configure private indexes as sources in pyproject.toml and provide credentials through environment variables (POETRY_HTTP_BASIC_<SOURCE>_USERNAME and _PASSWORD) in CI and Docker builds, not in files committed to the repository.

Poetry or uv?

uv installs from lock files much faster and has become popular for deployments. Poetry remains widely used; the deployment principles (lock file, main group only, no tool at runtime) are the same for both.

When to automate this

Run poetry check --lock and a clean install in CI on every pull request, and automate lock file updates with a dependency update bot so upgrades arrive as reviewable pull requests.

Next steps

FAQ

How do I install only production dependencies with Poetry?

Put development tools in a separate group and run poetry install --only main.

Where does Poetry create the virtual environment?

By default in a cache directory outside the project. Set virtualenvs.in-project = true to create .venv in the project directory, which is easier to reference from systemd and Docker.

Should Poetry be installed in the production Docker image?

No. Use it in a build stage to create the virtual environment, then copy only the environment into the runtime image.

Why does poetry install fail with "pyproject.toml changed significantly since poetry.lock was last generated"?

The lock file is out of date. Run poetry lock, review the changes, and commit the updated lock file.

Keep going