The runtime image carries 430 MB it never runs, including a Node.js runtime #154

Closed
opened 2026-09-09 11:38:36 +00:00 by tiagoagueda · 2 comments
Owner

What the scan found

Trivy 0.71.2 and Grype 0.115.0 were run against postulo:latest as built on the test
instance from 0.3.0 at d0cc4c8. The image is 906 MB, and about 430 MB of it is
things the application never runs
.

Cause one: the browser test tooling is installed

/app/.venv/lib/python3.14/site-packages/
    playwright/            136 MB, and it bundles its own Node.js binary
    pytest/                pytest 9.1.1
    pytest_playwright/
    pytest_base_url/

The Dockerfile looks right, which is why this went unnoticed:

RUN uv sync --locked --no-dev --extra server

--no-dev omits the dev group and nothing else. pyproject.toml has two groups and
declares both as defaults:

[dependency-groups]
dev = ["pytest>=8.4", "pytest-django>=4.14", "ruff>=0.16", "pre-commit>=4.0", ...]
e2e = ["playwright>=1.62", "pytest-playwright>=0.9"]

[tool.uv]
default-groups = ["dev", "e2e"]

So e2e is installed. Checked in the image rather than reasoned about: zero packages
from the dev group are present — no ruff, no factory-boy, no pre-commit — and all four
from e2e are. --no-dev is doing exactly what it says; it is not doing what the line
intends.

The comment above default-groups explains why both are defaults — "so that uv add and
uv sync never quietly remove the browser test's dependencies"
— which is a good reason
that collides with the image build.

A Node.js runtime in a Python image is the part that matters, more than the megabytes.
Trivy reports a Node.js scan target in a container that has no business running JavaScript,
and Playwright's driver exists to launch browsers. It is dormant, and it is a whole second
language runtime present because of a test tool.

Cause two: uv's download cache is left behind

/root/.cache/uv    296 MB

Every wheel uv sync unpacked is still there — including wheels for packages that were not
installed. Trivy scans them as real packages, which is why its report lists
root/.cache/uv/archive-v0/…/pytest_base_url-2.1.0.dist-info/METADATA beside the
application's own dependencies. uv writes this cache by default and the Dockerfile never
clears it.

What this asks for

An image that contains what it runs.

Worth being careful about

--no-default-groups is the fix for the first, and it should be spelled deliberately.
uv sync --locked --no-default-groups --extra server installs the project and the named
extra and nothing else; --no-dev --no-group e2e says the same thing and needs editing every
time a group is added. The first is the one that stays right.

UV_NO_CACHE=1, or removing the cache in the same layer, for the second. Deleting it in
a later RUN saves nothing — the bytes are already in the layer below.

A test can catch both, and should, because nothing else will. tests/test_image_build.py
already reads the Dockerfile for a different reason (#121); asserting that the sync line
excludes every group, and that the cache does not survive, is the same shape. The real check
is a built image, and that waits on #81.

Rebuilding is not free on the target hardware — around ten minutes on the Pi — so this is
worth doing together with anything else that changes the image.

Nothing here is exploitable today, and the issue should not pretend otherwise. Playwright
launches no browser, pytest is never imported, and the cache is inert. This is attack surface
and image weight, not a vulnerability: fewer things in the image is fewer things to be wrong
about later, and a 906 MB image on a Raspberry Pi is a slow pull and a full disk sooner.

## What the scan found Trivy 0.71.2 and Grype 0.115.0 were run against `postulo:latest` as built on the test instance from `0.3.0` at `d0cc4c8`. The image is **906 MB**, and about **430 MB of it is things the application never runs**. ## Cause one: the browser test tooling is installed ``` /app/.venv/lib/python3.14/site-packages/ playwright/ 136 MB, and it bundles its own Node.js binary pytest/ pytest 9.1.1 pytest_playwright/ pytest_base_url/ ``` The Dockerfile looks right, which is why this went unnoticed: ```dockerfile RUN uv sync --locked --no-dev --extra server ``` `--no-dev` omits the **`dev`** group and nothing else. `pyproject.toml` has two groups and declares both as defaults: ```toml [dependency-groups] dev = ["pytest>=8.4", "pytest-django>=4.14", "ruff>=0.16", "pre-commit>=4.0", ...] e2e = ["playwright>=1.62", "pytest-playwright>=0.9"] [tool.uv] default-groups = ["dev", "e2e"] ``` So `e2e` is installed. Checked in the image rather than reasoned about: **zero** packages from the `dev` group are present — no ruff, no factory-boy, no pre-commit — and **all four** from `e2e` are. `--no-dev` is doing exactly what it says; it is not doing what the line intends. The comment above `default-groups` explains why both are defaults — *"so that `uv add` and `uv sync` never quietly remove the browser test's dependencies"* — which is a good reason that collides with the image build. **A Node.js runtime in a Python image is the part that matters**, more than the megabytes. Trivy reports a `Node.js` scan target in a container that has no business running JavaScript, and Playwright's driver exists to launch browsers. It is dormant, and it is a whole second language runtime present because of a test tool. ## Cause two: uv's download cache is left behind ``` /root/.cache/uv 296 MB ``` Every wheel `uv sync` unpacked is still there — including wheels for packages that were not installed. Trivy scans them as real packages, which is why its report lists `root/.cache/uv/archive-v0/…/pytest_base_url-2.1.0.dist-info/METADATA` beside the application's own dependencies. `uv` writes this cache by default and the Dockerfile never clears it. ## What this asks for An image that contains what it runs. ## Worth being careful about **`--no-default-groups` is the fix for the first, and it should be spelled deliberately.** `uv sync --locked --no-default-groups --extra server` installs the project and the named extra and nothing else; `--no-dev --no-group e2e` says the same thing and needs editing every time a group is added. The first is the one that stays right. **`UV_NO_CACHE=1`, or removing the cache in the same layer, for the second.** Deleting it in a later `RUN` saves nothing — the bytes are already in the layer below. **A test can catch both, and should, because nothing else will.** `tests/test_image_build.py` already reads the Dockerfile for a different reason (#121); asserting that the sync line excludes every group, and that the cache does not survive, is the same shape. The real check is a built image, and that waits on #81. **Rebuilding is not free on the target hardware** — around ten minutes on the Pi — so this is worth doing together with anything else that changes the image. **Nothing here is exploitable today**, and the issue should not pretend otherwise. Playwright launches no browser, pytest is never imported, and the cache is inert. This is attack surface and image weight, not a vulnerability: fewer things in the image is fewer things to be wrong about later, and a 906 MB image on a Raspberry Pi is a slow pull and a full disk sooner.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 11:38:36 +00:00
Author
Owner

Measured the rest of the image while confirming this one. The two causes here are 432 MB of 906 MB — the uv cache is 296 MB and playwright is 136 MB — so fixing this issue alone takes the image to roughly 430 MB.

#157 carries the full breakdown and what is left afterwards: the uv binary (45 MB, load-bearing for plugin installs), the .po catalogues (17 MB, and nothing reads them at runtime), and Django admin's static files (6.6 MB, collected whether or not an admin is mounted). It also makes the structural point that the Python side of the build is single-stage, which is why build artefacts end up in the shipped layers at all.

Measured the rest of the image while confirming this one. The two causes here are **432 MB of 906 MB** — the uv cache is 296 MB and playwright is 136 MB — so fixing this issue alone takes the image to roughly 430 MB. #157 carries the full breakdown and what is left afterwards: the `uv` binary (45 MB, load-bearing for plugin installs), the `.po` catalogues (17 MB, and nothing reads them at runtime), and Django admin's static files (6.6 MB, collected whether or not an admin is mounted). It also makes the structural point that the Python side of the build is single-stage, which is *why* build artefacts end up in the shipped layers at all.
Author
Owner

uv sync --no-dev omitted only the dev group while pyproject.toml declares default-groups = ["dev", "e2e"], so the runtime image shipped Playwright, its browser driver and the rest of the browser suite. --no-default-groups omits every one of them.

--no-cache goes with it: uv's cache was a second copy of every wheel, in a layer nothing reads at runtime.

A third test asserts default-groups ⊆ dependency-groups and that there is more than one group, because the flag is only wrong when the two disagree — which is exactly the condition nobody notices. Verified in the built image: no dev-group package present, all four e2e ones gone.

Shipped in 42954cc on 0.3.0, with main kept level.

`uv sync --no-dev` omitted only the `dev` group while `pyproject.toml` declares `default-groups = ["dev", "e2e"]`, so the runtime image shipped Playwright, its browser driver and the rest of the browser suite. `--no-default-groups` omits every one of them. `--no-cache` goes with it: uv's cache was a second copy of every wheel, in a layer nothing reads at runtime. A third test asserts `default-groups ⊆ dependency-groups` and that there is more than one group, because the flag is only wrong when the two disagree — which is exactly the condition nobody notices. Verified in the built image: no `dev`-group package present, all four `e2e` ones gone. Shipped in `42954cc` on `0.3.0`, with `main` kept level.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#154
No description provided.