Docker – Choosing a Base Image and Keeping It Small

July 6, 20264 min readUpdated 8/21/2026

Lesson 3 showed where a 460 MB image's bytes actually are: 165 MB of Java runtime, 55 MB of base OS packages, 120 MB of dependencies, and 336 kB of application code.

So the FROM line is the size decision. Nothing you do to your own code moves the needle next to it.

What the suffixes mean

Measured on this machine, arm64, with docker image inspect:

eclipse-temurin:21-jdk                     500 MB
eclipse-temurin:21-jre                     340 MB
eclipse-temurin:21-jre-alpine              207 MB
node:22-alpine                             160 MB
nginxinc/nginx-unprivileged:1.27-alpine     49 MB
alpine:3.21                                  8 MB

Roughly, and in increasing order of how much they remove:

The default tag is a full Debian or Ubuntu userland — a package manager, a shell, coreutils, docs, locales. Comfortable, and mostly things your app never calls.

-slim is the same distribution with the documentation, locales and optional packages stripped. Same libc, same shell, same package manager. Usually the best effort-to-reward ratio, because nothing about it can surprise you.

-alpine is a different distribution built on musl libc rather than glibc. That is where the savings come from and also where the surprises come from — see below.

Distroless (Google's gcr.io/distroless/*) has no shell, no package manager, no coreutils. Your binary, its runtime, and CA certificates. The smallest and the hardest to debug: docker exec -it c sh does not work, because there is no sh.

scratch is genuinely empty. Only useful for a statically linked binary — Go, Rust — which is why you see it in Go projects and nowhere else.

The Alpine caveat, stated properly

musl is not a drop-in replacement for glibc. Three real consequences:

Anything with a compiled native extension needs a musl build. Python wheels and Node native modules are commonly published for glibc only, so pip install falls back to compiling from source — which turns a 30-second Alpine build into a ten-minute one and frequently needs build tools you then have to install. A "smaller" image that takes ten minutes to build is not obviously a win.

DNS resolution differs. musl historically had a smaller resolver with different search-domain and TCP-fallback behaviour. Mostly fine; occasionally the cause of an intermittent lookup failure that appears only in the Alpine image.

Some runtimes have known musl performance issues. musl's allocator has cost measurable throughput in allocation-heavy workloads. Measure rather than assume.

None of that means don't use Alpine. It means the size number is not the whole trade, and it is worth checking rather than adopting on reputation.

An experiment worth running

The API in this track ships on eclipse-temurin:21-jre. Alpine's JRE is 133 MB smaller, so: does it work?

FROM eclipse-temurin:21-jre-alpine AS runtime
RUN addgroup -S pizza && adduser -S -G pizza pizza

Note that even this two-line change is not literal. Alpine ships BusyBox, so groupadd/useradd do not exist and you want addgroup/adduser, with different flags. That is the Alpine tax in miniature.

$ docker image ls
REPOSITORY   TAG                  SIZE
pizza-api    dev                  460MB
pizza-api    alpine-experiment    327MB

$ docker run --rm pizza-api:alpine-experiment
  :: Spring Boot ::                (v4.1.0)
Starting PizzaSpringbootBackendApplication ... using Java 21.0.11 with PID 1

327 MB, and it boots. A real 133 MB saving for a JVM app with no native dependencies, which is the case Alpine handles well.

The point is less the number than the method: it took one build and one docker run to find out, and that beats any amount of arguing about it.

What actually shrinks an image

In descending order of effect, for a typical application:

1. A multi-stage build. 957 MB → 460 MB here. Nothing else comes close, and lesson 7 covers it.

2. A smaller base. 460 MB → 327 MB. One line, subject to the caveats above.

3. A runtime instead of a toolchain. JRE not JDK, python:3.13-slim not python:3.13. Often the same edit as (2).

4. Not committing caches. Chain apt-get install with rm -rf /var/lib/apt/lists/* in one RUN; use --no-install-recommends; use a cache mount for package managers so their downloads are never in a layer at all.

And what does not help: a later RUN rm of something an earlier layer added. Lesson 3 — the file is still in the earlier layer, the image is no smaller, and the file is recoverable.

Why it matters, and where it does not

Be clear about the actual cost, because image size is easy to over-optimise.

A smaller image does not make your application faster. It does not use less memory. It matters at pull time — the first deploy to a new host, an autoscaler adding an instance, a CI job on a cold runner — and it matters for what is in the image at all, which is a security question (lesson 9) more than a size one.

Layer sharing blunts even that. Ten services on one 21-jre base pull those 340 MB once. Which is a good argument for a consistent base across your services, and often a better use of an afternoon than shaving 40 MB off one of them.

Next: the user that container is running as.