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.
- Commit
poetry.lockand fail CI when it is out of date withpoetry check --lock. - Put development tools in a separate group (
[tool.poetry.group.dev.dependencies]). - For an application that is not a package, set
package-mode = false, or install with--no-root. - On servers, set
virtualenvs.in-project = trueand install withpoetry install --only main --no-interaction. - Point systemd at
.venv/bin/gunicornin the project directory. - 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
--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"]
- Copying
pyproject.tomlandpoetry.lockbefore 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
.venvto.dockerignoreso 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
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
- Compare with a faster installer in Deploy Django with uv for Reproducible Installs.
- For plain pip workflows, see Pin Django Dependencies with pip-tools and Hashes.
- Build the image with How to Dockerize a Django App for Production.
- If Gunicorn cannot import packages, see Fix ModuleNotFoundError When Starting Django with Gunicorn.
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.