Bar chart of download size for the same FastAPI app on five base images: python:3.12 420 MB, python:3.12-slim 52.1 MB, distroless 27.8 MB, python:3.12-alpine 26.9 MB, chiselled ubuntu/python 25.1 MB

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.

text
machine    Docker Desktop, Docker Engine 29.6.1, WSL2, 6 CPUs, linux/amd64 images
on disk    docker image ls, unpacked size of the whole image
download   compressed layer sizes from the registry manifest (what a cold pull fetches)
pull       one cold pull per base over a 13-18 Mbit/s line, nothing cached
start      docker run -d to the first HTTP 200, polling every 10 ms, 5 runs, median
memory     docker stats 8 s after start, idle
CVEs       Trivy 0.76.1, vulnerability DB of 27 Oct 2026, OS and language packages
date       27 Oct 2026; tags pulled that day

Python 3.12: the default image is 1.64 GB

Base image (OS)On diskDownloadStartIdle memoryShellCVEs: all / crit+high / fixable
python:3.12
Debian 13
1.64 GB420 MB0.72 s31.6 MiByes3,836 / 469 / 73
python:3.12-slim
Debian 13
215 MB52.1 MB0.63 s31.6 MiByes176 / 47 / 12
python:3.12-alpine
Alpine 3.24
111 MB26.9 MB0.72 s32.1 MiByes12 / 3 / 12
distroless python3-debian13
Debian 13 (Python 3.13)
113 MB27.8 MB0.93 s35.2 MiBno178 / 29 / 6
ubuntu/python:3.12-24.04 (chiselled)
Ubuntu 24.04
96.5 MB25.1 MB0.89 s34.2 MiBno6 / 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:

dockerfile
FROM python:3.12-slim AS build
COPY requirements.txt .
RUN pip install --no-cache-dir --target /deps -r requirements.txt

FROM ubuntu/python:3.12-24.04_stable
COPY --from=build /deps /deps
WORKDIR /app
COPY app.py .
ENV PYTHONPATH=/deps
USER 10001
ENTRYPOINT ["python3", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]

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):

dockerfile
FROM ubuntu/python:3.12-24.04_stable
COPY --from=build /deps /deps
# the stdlib ships without .pyc files; compile them once (exec form: there is no shell)
USER 0
RUN ["python3", "-m", "compileall", "-q", "/usr/lib/python3.12"]
USER 10001
# distroless: /usr/lib/python3.13, and switch back to USER 65532
Image (second run, same session)On diskDownloadStart (median of 5)Idle memory
python:3.12-slim215 MB52.1 MB0.66 s31.6 MiB
distroless113 MB27.8 MB1.00 s35.3 MiB
distroless + compileall131 MB32.2 MB0.58 s31.7 MiB
chiselled96.5 MB25.1 MB0.95 s34.0 MiB
chiselled + compileall113 MB29.3 MB0.52 s30.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 diskDownloadStartIdle memoryShellCVEs: all / crit+high / fixable
eclipse-temurin:21-jre
Ubuntu 26.04
456 MB115 MB0.37 s30.1 MiByes57 / 0 / 0
eclipse-temurin:21-jre-alpine
Alpine 3.24
286 MB74.1 MB0.35 s27.8 MiByes1 / 1 / 1
distroless java21-debian13
Debian 13
262 MB63.6 MB0.37 s30.3 MiBno64 / 4 / 0
ubuntu/jre:21-24.04 (chiselled)
Ubuntu 24.04
211 MB56.2 MB0.40 s29.0 MiBno14 / 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 diskDownloadStartIdle memoryShellCVEs: all / crit+high / fixable
dotnet/aspnet:8.0
Debian 12
327 MB92.5 MB0.60 s22.3 MiByes242 / 59 / 12
dotnet/aspnet:8.0-alpine
Alpine 3.24
160 MB49.7 MB0.55 s19.7 MiByes0 / 0 / 0
dotnet/aspnet:8.0-noble-chiseled
Ubuntu 24.04
163 MB50.6 MB0.61 s20.1 MiBno2 / 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)LayersDownloadCold pullEffective rate
python:3.127411.7 MB180.7 s2.3 MB/s
python:3.12-slim443.5 MB18.2 s2.4 MB/s
python:3.12-alpine418.0 MB18.6 s1.0 MB/s
distroless python3-debian134822.6 MB28.5 s0.8 MB/s
ubuntu/python 3.12 (chiselled)319.9 MB22.6 s0.9 MB/s
eclipse-temurin:21-jre6115.4 MB41.1 s2.8 MB/s
eclipse-temurin:21-jre-alpine574.1 MB38.2 s1.9 MB/s
distroless java21-debian133463.6 MB24.7 s2.6 MB/s
ubuntu/jre 21 (chiselled)356.2 MB20.9 s2.7 MB/s
dotnet/aspnet:8.0692.5 MB18.9 s4.9 MB/s
dotnet/aspnet:8.0-alpine649.6 MB14.1 s3.5 MB/s
dotnet/aspnet:8.0-noble-chiseled550.6 MB15.3 s3.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 imageDownloadAt 100 Mbit/sAt 1 Gbit/s100 apps at 1 Gbit/s
Python, python:3.12420 MB33.6 s3.4 s5 min 36 s
Python, slim52.1 MB4.2 s0.42 s42 s
Python, chiselled + compileall29.3 MB2.3 s0.23 s23 s
Java, eclipse-temurin:21-jre115 MB9.2 s0.92 s1 min 32 s
Java, chiselled56.2 MB4.5 s0.45 s45 s
.NET, aspnet:8.092.5 MB7.4 s0.74 s1 min 14 s
.NET, Alpine49.7 MB4.0 s0.40 s40 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

RuntimePickWhyWatch out for
Pythonpython:3.12-slim to start; chiselled or distroless with compileall when it worksSlim keeps pip, a shell and glibc, so every wheel installs and you can debug in the containerNever ship the default tag. Alpine uses musl: packages without musllinux wheels build from source and need a compiler
Javaubuntu/jre chiselled or distroless java21Half the download of Temurin's default, same start, no shell to exploitNo shell for docker exec; use the distroless :debug tag or a sidecar to debug
.NETaspnet:8.0-noble-chiseled, or -alpine if you need a shellTag change only, 45 to 46% smaller download, 0 to 2 findings, non-root by defaultChiselled 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.

The systemi Deploy Python docs page: detected by requirements.txt or pyproject.toml, Python 3.12 on Debian slim, start with uvicorn main:app when fastapi is in the requirements
On systemi you don't pick a base image: Python apps build on Python 3.12 on Debian slim, the default this test recommends (systemi.in/docs/python).

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