tox, nox, pre-commit & Docker

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the Python Reference bibliography.

Poetry manages one environment per project; test runners, Git hooks and container builds bring their own environments. This page shows how to make each of them install exactly what poetry.lock says, with Poetry 2.5.1, tox 4.67.1, nox 2026.10.20, nox-poetry 1.2.0, pre-commit 4.7.0 and Docker 29.9.1. The examples ran on 2026-10-22 on Linux with CPython 3.13.16 and 3.14.6, on the birdtally project, whose test, lint, docs and dev groups are PEP 735 [dependency-groups]. Scratch paths are shortened to ….

tox

tox 4 builds the project through its PEP 517 back end, so it works with any Poetry project. The question is who installs the dependencies:

Approach Effect

tox only (deps = pytest)

tox builds an sdist and pip installs it, resolving the dependencies afresh: the lock is ignored.

Poetry in commands_pre, skip_install = true

Poetry installs the locked versions (and the project, editable) into the tox environment. Tests run against the source tree.

Poetry in commands_pre, with tox installing the package

tox installs the built package first, then Poetry brings the dependencies to the locked versions.

The second form, with poetry sync so that each environment holds the lock and nothing else:

[tox]
env_list = py313, py314

[testenv]
skip_install = true
allowlist_externals = poetry
commands_pre =
    poetry sync --only main,test
commands =
    poetry env info -p
    poetry run pytest -q tests/
tox -r -e py314
py314: remove tox env folder …/birdtally/.tox/py314
py314: commands_pre[0]> poetry sync --only main,test
Installing dependencies from lock file

Package operations: 5 installs, 0 updates, 0 removals

  - Installing iniconfig (2.3.1)
  - Installing packaging (26.3)
  - Installing pluggy (1.7.0)
  - Installing pygments (2.21.0)
  - Installing pytest (9.1.1)

Installing the current project: birdtally (0.1.0)
py314: commands[0]> poetry env info -p
…/birdtally/.tox/py314
py314: commands[1]> poetry run pytest -q tests/
..                                                                       [100%]
2 passed in 0.01s
  py314: OK (3.36=setup[0.19]+cmd[1.20,0.66,1.31] seconds)
  congratulations :) (3.38 seconds)

Poetry uses the tox environment because tox activates it (VIRTUAL_ENV); poetry env info -p proves it. Three traps:

  • --with test installs every group. PEP 735 groups and [tool.poetry.group] groups are non-optional unless marked optional = true, so --with adds nothing to them: the first run of this example, with poetry sync --with test, installed 22 packages including mkdocs and ruff. Use --only main,test.

  • poetry must be on PATH and listed in allowlist_externals, or tox refuses to run it.

  • Credentials: tox passes only a few environment variables through. Add passenv = POETRY_HTTP_BASIC_* (or the variables you use; DBUS_SESSION_BUS_ADDRESS for a Linux desktop keyring), or Poetry cannot reach private sources.

The Poetry FAQ’s own examples still use poetry install --no-root --sync; --sync has been deprecated since 2.0 in favour of poetry sync.

nox

The Poetry FAQ points to the third-party nox-poetry, whose session.install builds the project as a wheel and installs dependencies constrained by poetry export. A plain nox session can also call poetry sync into the session’s virtual environment:

import nox
from nox_poetry import session


@session(python=["3.13", "3.14"])
def tests(session):
    session.install(".", "pytest")
    session.run("pytest", "-q", "tests/")


@nox.session(python="3.14")
def tests_sync(session):
    session.run_install(
        "poetry", "sync", "--only", "main,test",
        external=True,
        env={"VIRTUAL_ENV": session.virtualenv.location},
    )
    session.run("pytest", "-q", "tests/")
nox > Running session tests-3.14
nox > Creating virtual environment (virtualenv) using python3 in .nox/tests-3-14
nox > …/python -m build …/birdtally --outdir …/birdtally/dist --wheel
…
nox > poetry export --format=requirements.txt --without-hashes
nox > python -m pip install --constraint=.nox/tests-3-14/tmp/requirements.txt file://…/birdtally/dist/birdtally-0.1.0-py3-none-any.whl pytest
nox > pytest -q tests/
..                                                                       [100%]
2 passed in 0.01s
nox > Session tests-3.14 was successful in 6 seconds.
nox > Running session tests_sync
nox > Creating virtual environment (virtualenv) using python3 in .nox/tests_sync
nox > poetry sync --only main,test
Installing dependencies from lock file

Package operations: 5 installs, 0 updates, 0 removals
…
Installing the current project: birdtally (0.1.0)
nox > pytest -q tests/
..                                                                       [100%]
2 passed in 0.01s
nox > Ran 3 sessions in 15 seconds:
nox > * tests-3.13: success, took 7 seconds
nox > * tests-3.14: success, took 6 seconds
nox > * tests_sync: success, took 2 seconds

Look at the export nox-poetry ran: main dependencies only. birdtally has none, so the constraints file was empty and pytest was not pinned by the lock at all; it got 9.1.1 only because that was also the newest release. With nox-poetry, dependencies from groups are not locked; the poetry sync session is. nox-poetry needs poetry export, that is the export plugin (see Exporting Requirements & Bundling).

pre-commit

The Poetry repository provides four hooks, each running the Poetry command of the same name with the same arguments:

Hook Purpose

poetry-check

poetry check: pyproject.toml valid and the lock in step with it (add --lock to require the lock).

poetry-lock

poetry lock: updates the lock file when pyproject.toml changed.

poetry-export

poetry export, by default -f requirements.txt -o requirements.txt; needs the export plugin.

poetry-install

poetry install; meant for post-checkout/post-merge hooks (pre-commit install -t post-checkout -t post-merge).

repos:
  - repo: https://github.com/python-poetry/poetry
    rev: 2.5.1
    hooks:
      - id: poetry-check
        args: ["--lock"]
      - id: poetry-lock

Giving args: replaces the hook’s defaults, so repeat them all. The documentation’s poetry-export example still passes the deprecated --dev; use --with dev (or --all-groups) instead. For a pyproject.toml in a subdirectory add ["-C", "./subdirectory"]. After adding a dependency to pyproject.toml by hand without locking:

pre-commit run --all-files
poetry-check.............................................................Failed
- hook id: poetry-check
- exit code: 1

Error: pyproject.toml changed significantly since poetry.lock was last generated. Run `poetry lock` to fix the lock file.

poetry-lock..............................................................Failed
- hook id: poetry-lock
- files were modified by this hook

Resolving dependencies...

Writing lock file

The commit is stopped, and poetry.lock is updated in the working tree, ready to be added. With both files consistent the hooks print Passed.

pre-commit autoupdate takes the newest tag on the default branch, but Poetry tags its releases on stable branches, so autoupdate does not move to the latest Poetry. Update rev by hand, restrict autoupdate with --repo, or use the third-party pre-commit-update hook.

Docker: build with Poetry, ship the environment

CI Caching & Containers shows the layer-caching two-step in a single-stage image. A multi-stage build adds two things: Poetry and its own dependencies stay out of the runtime image, and the project is installed from its wheel instead of in editable mode, so the environment can be copied on its own:

# syntax=docker/dockerfile:1
FROM python:3.14-slim AS builder

ENV POETRY_VERSION=2.5.1 \
    POETRY_HOME=/opt/poetry \
    POETRY_VIRTUALENVS_IN_PROJECT=true \
    POETRY_NO_INTERACTION=1 \
    PIP_NO_CACHE_DIR=1
RUN python -m venv "$POETRY_HOME" \
    && "$POETRY_HOME/bin/pip" install "poetry==$POETRY_VERSION"

WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN --mount=type=cache,target=/root/.cache/pypoetry \
    "$POETRY_HOME/bin/poetry" sync --only main --extras pretty --no-root --no-directory

COPY README.md LICENSE CHANGELOG.md ./
COPY src/ ./src
RUN "$POETRY_HOME/bin/poetry" build --format wheel \
    && .venv/bin/pip install --no-deps dist/*.whl

FROM python:3.14-slim AS runtime
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
USER nobody
ENTRYPOINT ["birdtally"]
docker build -t birdtally:poetry-ms .
docker images birdtally --format '{{.Tag}} {{.Size}}'
docker run --rm -v "$PWD/survey.csv:/data/survey.csv:ro" birdtally:poetry-ms /data/survey.csv
#14 [builder 8/8] RUN "/opt/poetry/bin/poetry" build --format wheel     && .venv/bin/pip install --no-deps dist/*.whl
#14 0.777 Building birdtally (0.1.0)
#14 0.781 Building wheel
#14 0.783   - Building wheel
#14 0.790   - Built birdtally-0.1.0-py3-none-any.whl
#14 2.761 Processing ./dist/birdtally-0.1.0-py3-none-any.whl
#14 2.866 Installing collected packages: birdtally
#14 2.878 Successfully installed birdtally-0.1.0
#14 DONE 3.0s

#15 [runtime 2/2] COPY --from=builder /app/.venv /app/.venv
#15 DONE 0.2s
…
poetry-ms 203MB
pip 224MB
poetry 346MB
{
  "lapwing": 7,
  "redshank": 2
}

The multi-stage image (203 MB) is smaller than the single-stage Poetry image (346 MB) of the containers page and the pip image (224 MB); the base image is 179 MB. The runtime stage holds birdtally, rich with its three dependencies and pip, and no poetry command. Rules that make it work:

  • Use the same base image in both stages: the copied venv points at the base image’s interpreter by absolute path.

  • Keep .venv, dist and .git out of the build context with a .dockerignore, or a local .venv is copied over the built one.

  • --mount=type=cache keeps Poetry’s download cache between builds without putting it in a layer; it needs BuildKit, the default builder.

  • Run as a non-root user in the runtime stage.

The other route, exporting a hashed requirements.txt and installing it with pip --no-deps in an image without Poetry, is on CI Caching & Containers.

The image was built through the machine’s TLS-inspecting proxy with --network host --build-arg HTTPS_PROXY=… and a base image that trusts the proxy’s CA; neither belongs in a normal build.

Continuous integration

Poetry’s documentation has no GitHub Actions or CI-caching guide: its CI advice is the installer’s "CI recommendations" (pin the version, install with pipx or into a dedicated venv) and the Docker FAQ entry above. A workflow that caches Poetry’s environments with actions/setup-python is on CI Caching & Containers; release workflows are on GitHub Actions & GitLab CI Release Workflows.