diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 0642115..039a21f 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -7,7 +7,7 @@
   // works, alongside the usual Python setup.
   "postCreateCommand": {
     "bitbake": "test -d /opt/workdir/https___git_openembedded_org_bitbake || git clone https://git.openembedded.org/bitbake /opt/workdir/https___git_openembedded_org_bitbake",
-    "python": "pip install ruff pytest && python3 manage.py migrate"
+    "python": "pip install -r requirements-dev.txt && python3 manage.py migrate"
   },
   "remoteEnv": {
     "PYTHONPATH": "/opt/workdir/https___git_openembedded_org_bitbake/lib:${containerEnv:PYTHONPATH}"
diff --git a/README.devel.md b/README.devel.md
index f5b56ec..64106d0 100644
--- a/README.devel.md
+++ b/README.devel.md
@@ -170,10 +170,13 @@ worker, for emails sent from background tasks) instead of being sent.
 
 ## Running the tests
 
-The tests use pytest and pytest-django, which are not in `requirements.txt`:
+The tests use pytest and pytest-django. These and the other development tools
+are pinned in `requirements-dev.txt`, which the dev container and
+`docker/run-tests.sh` install for you. Elsewhere, install them on top of
+`requirements.txt`:
 
 ```sh
-pip install pytest pytest-django
+pip install -r requirements-dev.txt
 ```
 
 To run the full suite as CI does, in the Docker image against a throwaway
diff --git a/docker/run-tests.sh b/docker/run-tests.sh
index e655611..5da56b3 100755
--- a/docker/run-tests.sh
+++ b/docker/run-tests.sh
@@ -31,11 +31,13 @@ docker run --rm --network $NET -u root \
     -e SECRET_KEY=ci-only \
     -e GIT_AUTHOR_NAME=ci -e GIT_AUTHOR_EMAIL=ci@example.com \
     -e GIT_COMMITTER_NAME=ci -e GIT_COMMITTER_EMAIL=ci@example.com \
-    -v "$PWD/tests:/src/tests:ro" -w /opt/layerindex "$IMAGE" sh -ec '
+    -v "$PWD/tests:/src/tests:ro" \
+    -v "$PWD/requirements-dev.txt:/src/requirements-dev.txt:ro" \
+    -w /opt/layerindex "$IMAGE" sh -ec '
         apt-get update -qq && apt-get install -y -qq tree >/dev/null
         cp -r /src/tests /opt/layerindex/
         chown -R layers /opt/layerindex
         su layers -s /bin/sh -c "set -e
             git clone -q https://git.openembedded.org/bitbake /opt/workdir/https___git_openembedded_org_bitbake
-            pip3 install -q --user --no-warn-script-location pytest==9.0.3 pytest-django==4.12.0
+            pip3 install -q --user --no-warn-script-location -r /src/requirements-dev.txt
             python3 -m pytest -rfE -p no:cacheprovider tests/"'
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..489651e
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,3 @@
+pytest==9.0.3
+pytest-django==4.12.0
+ruff==0.17.5
