
The first line of a Dockerfile decides how big your image is, how long a new server takes to pull it, and how many security findings a scanner reports about it. We built the same hello app for Python, Java and .NET on every official base we could find (the default, slim, Alpine, Google's distroless and Canonical's chiselled Ubuntu), then measured image size, cold pull time, start time, idle memory and Trivy CVE counts for each one: 12 images, plus two fixed Python variants.
Short version: leaving the default python:3.12 for python:3.12-slim is the big win (420 MB download down to 52 MB). Going further to Alpine, distroless or chiselled halves that again, cuts the CVE list from 176 to as few as 6, and costs nothing at start, with one catch: the distroless and chiselled Python images ship without compiled bytecode and start 0.3 s slower until you add one build step. For Java and .NET the smaller bases download 36 to 51% less with the same start time.
How we measured
Each app answers GET / with {"hello":"world"} on port 8080 as a non-root user, the same apps as in our cold start test. Python is FastAPI 0.120 with uvicorn 0.38, Java is the JDK's built-in HttpServer in a plain jar, .NET is an ASP.NET Core 8 minimal API. Only the final FROM line changes; builds happen in a full SDK or -slim stage where the base has no pip or shell.
Python 3.12: the default image is 1.64 GB
| Base image (OS) | On disk | Download | Start | Idle memory | Shell | CVEs: all / crit+high / fixable |
|---|---|---|---|---|---|---|
| python:3.12 Debian 13 | 1.64 GB | 420 MB | 0.72 s | 31.6 MiB | yes | 3,836 / 469 / 73 |
| python:3.12-slim Debian 13 | 215 MB | 52.1 MB | 0.63 s | 31.6 MiB | yes | 176 / 47 / 12 |
| python:3.12-alpine Alpine 3.24 | 111 MB | 26.9 MB | 0.72 s | 32.1 MiB | yes | 12 / 3 / 12 |
| distroless python3-debian13 Debian 13 (Python 3.13) | 113 MB | 27.8 MB | 0.93 s | 35.2 MiB | no | 178 / 29 / 6 |
| ubuntu/python:3.12-24.04 (chiselled) Ubuntu 24.04 | 96.5 MB | 25.1 MB | 0.89 s | 34.2 MiB | no | 6 / 3 / 6 |
The default python:3.12 tag carries compilers, headers and hundreds of Debian packages so that any pip install can build from source. That is 1.64 GB on disk and 3,836 scanner findings, nearly all in packages a FastAPI app never touches. Slim drops them: 87% less download and 95% fewer findings, same start time.
Distroless has no Python 3.12 image: its Debian 12 image ships 3.11 and its Debian 13 image 3.13, so that row runs 3.13. Neither distroless nor chiselled has pip or a shell, so dependencies are installed in a python:*-slim build stage and copied in:
Why the smallest Python images started slowest, and the one-line fix
Distroless and chiselled were the smallest Python images but took 0.89 to 0.93 s to start against 0.63 s for slim, and used 3 to 4 MiB more memory. The cause: their standard library has no __pycache__ folders (0 against 132 in slim). A non-root user can't write bytecode next to the read-only library, so Python compiles every stdlib module it imports again on every start. Importing fastapi and uvicorn took 0.34 s on slim, 0.55 s on distroless and 0.67 s on chiselled.
The fix is to compile the library once at build time. There is no shell, so RUN has to use the exec form, and it has to run as user 0 (the chiselled image has no root entry in its passwd file, so USER root fails):
| Image (second run, same session) | On disk | Download | Start (median of 5) | Idle memory |
|---|---|---|---|---|
| python:3.12-slim | 215 MB | 52.1 MB | 0.66 s | 31.6 MiB |
| distroless | 113 MB | 27.8 MB | 1.00 s | 35.3 MiB |
| distroless + compileall | 131 MB | 32.2 MB | 0.58 s | 31.7 MiB |
| chiselled | 96.5 MB | 25.1 MB | 0.95 s | 34.0 MiB |
| chiselled + compileall | 113 MB | 29.3 MB | 0.52 s | 30.7 MiB |
Compiling adds 16 to 18 MB on disk and 4 MB of download, and turns the slowest starts into the fastest: the chiselled image with bytecode answered in 0.52 s, 0.14 s ahead of slim, with the import of fastapi and uvicorn down from 0.67 s to 0.28 s. It still downloads 44% less than slim.
Java 21: chiselled is less than half the default
| Base image (OS) | On disk | Download | Start | Idle memory | Shell | CVEs: all / crit+high / fixable |
|---|---|---|---|---|---|---|
| eclipse-temurin:21-jre Ubuntu 26.04 | 456 MB | 115 MB | 0.37 s | 30.1 MiB | yes | 57 / 0 / 0 |
| eclipse-temurin:21-jre-alpine Alpine 3.24 | 286 MB | 74.1 MB | 0.35 s | 27.8 MiB | yes | 1 / 1 / 1 |
| distroless java21-debian13 Debian 13 | 262 MB | 63.6 MB | 0.37 s | 30.3 MiB | no | 64 / 4 / 0 |
| ubuntu/jre:21-24.04 (chiselled) Ubuntu 24.04 | 211 MB | 56.2 MB | 0.40 s | 29.0 MiB | no | 14 / 4 / 14 |
The JVM starts the same on every base (0.35 to 0.40 s, within run-to-run noise), so the choice is about size and maintenance. The chiselled JRE downloads 51% less than Temurin's default. Its 14 findings are all in one file, /usr/bin/pebble, the Go-built init that Canonical's images use as entrypoint (Go stdlib 1.27.1, fixed in 1.27.2 and 1.27.3); Alpine's single finding is in brotli-libs. Distroless's 64 are Debian glibc and similar entries with no fix released, several of them from 2010 to 2019 and marked low.
Distroless Java images set the entrypoint to java -jar, so CMD ["app.jar"] is all you add. The chiselled JRE starts Pebble by default; we set ENTRYPOINT ["java", "-jar", "/app/app.jar"] to run the JVM directly.
.NET 8: Alpine and chiselled both halve it
| Base image (OS) | On disk | Download | Start | Idle memory | Shell | CVEs: all / crit+high / fixable |
|---|---|---|---|---|---|---|
| dotnet/aspnet:8.0 Debian 12 | 327 MB | 92.5 MB | 0.60 s | 22.3 MiB | yes | 242 / 59 / 12 |
| dotnet/aspnet:8.0-alpine Alpine 3.24 | 160 MB | 49.7 MB | 0.55 s | 19.7 MiB | yes | 0 / 0 / 0 |
| dotnet/aspnet:8.0-noble-chiseled Ubuntu 24.04 | 163 MB | 50.6 MB | 0.61 s | 20.1 MiB | no | 2 / 0 / 0 |
For .NET the switch is a tag change in the final stage and nothing else: the same dotnet publish output ran on all three. Alpine and chiselled download 45 to 46% less, use 2 to 3 MiB less memory, and report 0 and 2 findings against 242 for the Debian 12 default, of which 59 rated critical or high and 2 of those fixable. Microsoft's chiselled images also come without a shell or package manager and run as a non-root user (UID 1654) by default.
Pull time: what a cold pull cost on our line
| Base image (no app) | Layers | Download | Cold pull | Effective rate |
|---|---|---|---|---|
| python:3.12 | 7 | 411.7 MB | 180.7 s | 2.3 MB/s |
| python:3.12-slim | 4 | 43.5 MB | 18.2 s | 2.4 MB/s |
| python:3.12-alpine | 4 | 18.0 MB | 18.6 s | 1.0 MB/s |
| distroless python3-debian13 | 48 | 22.6 MB | 28.5 s | 0.8 MB/s |
| ubuntu/python 3.12 (chiselled) | 3 | 19.9 MB | 22.6 s | 0.9 MB/s |
| eclipse-temurin:21-jre | 6 | 115.4 MB | 41.1 s | 2.8 MB/s |
| eclipse-temurin:21-jre-alpine | 5 | 74.1 MB | 38.2 s | 1.9 MB/s |
| distroless java21-debian13 | 34 | 63.6 MB | 24.7 s | 2.6 MB/s |
| ubuntu/jre 21 (chiselled) | 3 | 56.2 MB | 20.9 s | 2.7 MB/s |
| dotnet/aspnet:8.0 | 6 | 92.5 MB | 18.9 s | 4.9 MB/s |
| dotnet/aspnet:8.0-alpine | 6 | 49.6 MB | 14.1 s | 3.5 MB/s |
| dotnet/aspnet:8.0-noble-chiseled | 5 | 50.6 MB | 15.3 s | 3.3 MB/s |
One cold pull each on a 13 to 18 Mbit/s line, so read the large gaps, not the decimals. The 412 MB default Python image took three minutes; everything under 120 MB took 14 to 41 s. Below about 50 MB the pull time stops shrinking with size: registry round trips, token requests and per-layer work take over, and distroless's 48 small layers made it the slowest of the small Python bases, at 0.8 MB/s.
On a server the download is what scales with size. Worked out from the compressed sizes above, for the app image itself:
| App image | Download | At 100 Mbit/s | At 1 Gbit/s | 100 apps at 1 Gbit/s |
|---|---|---|---|---|
| Python, python:3.12 | 420 MB | 33.6 s | 3.4 s | 5 min 36 s |
| Python, slim | 52.1 MB | 4.2 s | 0.42 s | 42 s |
| Python, chiselled + compileall | 29.3 MB | 2.3 s | 0.23 s | 23 s |
| Java, eclipse-temurin:21-jre | 115 MB | 9.2 s | 0.92 s | 1 min 32 s |
| Java, chiselled | 56.2 MB | 4.5 s | 0.45 s | 45 s |
| .NET, aspnet:8.0 | 92.5 MB | 7.4 s | 0.74 s | 1 min 14 s |
| .NET, Alpine | 49.7 MB | 4.0 s | 0.40 s | 40 s |
Time = MB x 8 / Mbit/s, so 420 MB x 8 / 1,000 = 3.4 s. The last column is the worst case of moving 100 such apps to a fresh server with no layers shared. In practice apps on the same base tag share their base layers on disk and in the pull, so 100 slim Python apps store the 43.5 MB base once and fetch only their own layers on top.
What the CVE counts mean
A scanner counts every known vulnerability in every installed package, whether your app uses it or not, and Debian and Ubuntu list many low-rated issues they have decided not to fix. That is why the counts that matter for action are the fixable ones: 73 for the default Python image, 12 for slim, 0 to 14 for the small bases. Fewer packages means fewer findings and fewer patch releases to chase, which is the real gain of distroless and chiselled images.
The base doesn't fix your own dependencies. Every Python image here reported the same 6 findings in Starlette 0.48.0, pulled in by our pinned FastAPI 0.120.0 (the oldest is fixed in Starlette 0.49.1, the newest in 1.3.1). On chiselled that was the entire list; Alpine, slim and the default image add 6 more in the pip they ship. Upgrading FastAPI does more for that app's report than any base switch.
Which base to pick for a small SaaS app
| Runtime | Pick | Why | Watch out for |
|---|---|---|---|
| Python | python:3.12-slim to start; chiselled or distroless with compileall when it works | Slim keeps pip, a shell and glibc, so every wheel installs and you can debug in the container | Never ship the default tag. Alpine uses musl: packages without musllinux wheels build from source and need a compiler |
| Java | ubuntu/jre chiselled or distroless java21 | Half the download of Temurin's default, same start, no shell to exploit | No shell for docker exec; use the distroless :debug tag or a sidecar to debug |
| .NET | aspnet:8.0-noble-chiseled, or -alpine if you need a shell | Tag change only, 45 to 46% smaller download, 0 to 2 findings, non-root by default | Chiselled has no ICU or tzdata by default: use the -extra variant if you need cultures or time zones |
Pick by what you will need inside the container at 2 am. A shell (slim, Alpine) lets you docker exec in and look around; no shell (distroless, chiselled) means a smaller image and less to patch, and you debug from logs or a debug image. Whatever you pick, pin the tag, rebuild regularly so you get the base's fixes, and scan your own image: on the small bases, our own dependencies were most of what was left.
For what these runtimes use once they are running, see our idle memory test of the same apps.

Sources: our measurements of 27 Oct 2026 (scripts and raw results kept with the test apps); the official image notes on Docker Hub's python page (slim and Alpine/musl caveats); GoogleContainerTools/distroless (no shell or package manager, :debug tags); Microsoft's chiselled .NET containers announcement (21 Nov 2023); Canonical's ubuntu/python image page; Trivy 0.76.1.
systemi builds and runs Python, Java, .NET, PHP and static apps in their own containers on lean bases, with a PostgreSQL database per app and a subdomain.
See how deploys work

